├── .github ├── dependabot.yml └── workflows │ ├── integration.yml │ └── rust.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── dev_docs ├── architecture.md ├── design.md ├── project_plan.md └── style.md ├── resources ├── bash.sh ├── fish.sh ├── pwsh.ps1 └── zsh.sh ├── src ├── allowlist.rs ├── builtins.rs ├── commands │ ├── allow_command.rs │ ├── configure_shell.rs │ ├── get_command.rs │ ├── init.rs │ ├── list.rs │ ├── mod.rs │ ├── run.rs │ └── run_command.rs ├── environment.rs ├── lib.rs ├── main.rs ├── parsers │ ├── mod.rs │ ├── parse_github_actions.rs │ ├── parse_gradle.rs │ ├── parse_makefile.rs │ ├── parse_package_json.rs │ ├── parse_pom_xml.rs │ ├── parse_pyproject_toml.rs │ └── parse_taskfile.rs ├── prompt.rs ├── runner.rs ├── runners │ ├── mod.rs │ ├── runners_package_json.rs │ └── runners_pyproject_toml.rs ├── task_discovery.rs ├── task_shadowing.rs └── types.rs └── tests ├── Dockerfile.builder ├── docker_bash ├── Dockerfile ├── bashrc.test └── test_bash.sh ├── docker_fish ├── Dockerfile ├── config.fish.test └── test_fish.sh ├── docker_noinit ├── Dockerfile └── test_noinit.sh ├── docker_pwsh ├── Dockerfile ├── Microsoft.PowerShell_profile.ps1 └── test_pwsh.ps1 ├── docker_unit ├── Dockerfile └── test_unit.sh ├── docker_zsh ├── Dockerfile ├── test_zsh.sh └── zshrc.test ├── run_tests.sh └── task_definitions ├── Makefile ├── Taskfile.yml ├── assets_py ├── __init__.py └── main.py ├── build.gradle ├── build.gradle.kts ├── github_actions └── .github │ └── workflows │ └── test.yml ├── package-lock.json ├── package.json ├── poetry.lock ├── pom.xml ├── pyproject.toml ├── setup.py └── uv.lock /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuring-dependabot-version-updates#example-dependabotyml-file 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "cargo" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | rebase-strategy: "disabled" 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | schedule: 13 | interval: weekly -------------------------------------------------------------------------------- /.github/workflows/integration.yml: -------------------------------------------------------------------------------- 1 | name: Integration Tests 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | # Add workflow-level permissions 10 | permissions: 11 | contents: read 12 | packages: write 13 | 14 | env: 15 | CARGO_TERM_COLOR: always 16 | PLATFORM: linux/amd64 17 | 18 | jobs: 19 | build-base: 20 | name: Build Base Image 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v3 27 | with: 28 | buildkitd-flags: --debug 29 | 30 | - name: Build and (locally) load base image 31 | uses: docker/build-push-action@v6 32 | with: 33 | context: . 34 | file: tests/Dockerfile.builder 35 | platforms: ${{ env.PLATFORM }} 36 | # Remote layer cache 37 | cache-from: type=gha,scope=dela-builder 38 | cache-to: type=gha,scope=dela-builder,mode=max 39 | # Let Buildx skip the cache export when nothing changed 40 | # Keep the image on the runner 41 | load: true 42 | push: false 43 | tags: dela-builder:latest 44 | 45 | shell-tests: 46 | name: Shell Tests 47 | needs: build-base 48 | runs-on: ubuntu-latest 49 | strategy: 50 | matrix: 51 | shell: [unit, noinit, zsh, bash, fish, pwsh] 52 | fail-fast: false 53 | 54 | steps: 55 | - uses: actions/checkout@v4 56 | 57 | - name: Set up Docker Buildx 58 | uses: docker/setup-buildx-action@v3 59 | with: 60 | buildkitd-flags: --debug 61 | 62 | - name: Load cached builder image 63 | uses: docker/build-push-action@v6 64 | with: 65 | context: . 66 | file: tests/Dockerfile.builder 67 | platforms: ${{ env.PLATFORM }} 68 | cache-from: type=gha,scope=dela-builder 69 | load: true 70 | tags: dela-builder:latest 71 | 72 | - name: Run ${{ matrix.shell }} tests 73 | env: 74 | VERBOSE: 1 75 | DOCKER_PLATFORM: ${{ env.PLATFORM }} 76 | BUILDER_IMAGE: dela-builder:latest 77 | run: ./tests/run_tests.sh ${{ matrix.shell }} -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | test: 14 | name: Test 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Install Rust toolchain 20 | uses: dtolnay/rust-toolchain@stable 21 | with: 22 | components: rustfmt 23 | 24 | - name: Cache dependencies 25 | uses: actions/cache@v4 26 | with: 27 | path: | 28 | ~/.cargo/registry 29 | ~/.cargo/git 30 | target 31 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 32 | 33 | - name: Check formatting 34 | run: cargo fmt --all -- --check 35 | 36 | - name: Run tests 37 | run: cargo test --verbose 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .vscode 3 | **/.venv 4 | **/*.egg-info 5 | **/__pycache__ 6 | .env 7 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dela" 3 | version = "0.0.5" 4 | edition = "2021" 5 | license = "MIT" 6 | authors = ["Alexander Yankov"] 7 | description = "A task runner that delegates the work to other tools" 8 | repository = "https://github.com/aleyan/dela" 9 | documentation = "https://github.com/aleyan/dela#readme" 10 | readme = "README.md" 11 | keywords = ["task-runner", "automation", "build", "make"] 12 | categories = ["command-line-utilities", "development-tools", "development-tools::build-utils"] 13 | 14 | [dependencies] 15 | clap = { version = "4.5.38", features = ["derive"] } 16 | makefile-lossless = "0.2.1" 17 | serde = { version = "1.0.219", features = ["derive"] } 18 | serde_json = "1.0.140" 19 | serde_yaml = "0.9.34" 20 | toml = "0.8.22" 21 | once_cell = "1.21.3" 22 | roxmltree = "0.20.0" 23 | regex = "1.11.1" 24 | 25 | [dev-dependencies] 26 | tempfile = "3.20.0" 27 | serial_test = "3.2.0" 28 | libc = "0.2.172" 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Alexander Yankov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build tests tests_integration test_unit test_noinit test_zsh test_bash test_fish test_pwsh run install builder publish 2 | 3 | # Default to non-verbose output 4 | VERBOSE ?= 0 5 | 6 | # Load environment variables from .env file if it exists 7 | -include .env 8 | 9 | build: 10 | @echo "Building dela..." 11 | cargo build 12 | 13 | tests: 14 | @echo "Running tests..." 15 | cargo test 16 | 17 | # Build the base builder image 18 | builder: 19 | @echo "Building base builder image..." 20 | docker build -t dela-builder -f tests/Dockerfile.builder . 21 | 22 | # Individual shell test targets 23 | test_unit: builder 24 | VERBOSE=$(VERBOSE) ./tests/run_tests.sh unit; 25 | 26 | test_noinit: builder 27 | VERBOSE=$(VERBOSE) ./tests/run_tests.sh noinit; 28 | 29 | test_zsh: builder 30 | VERBOSE=$(VERBOSE) ./tests/run_tests.sh zsh; 31 | 32 | test_bash: builder 33 | VERBOSE=$(VERBOSE) ./tests/run_tests.sh bash; 34 | 35 | test_fish: builder 36 | VERBOSE=$(VERBOSE) ./tests/run_tests.sh fish; 37 | 38 | test_pwsh: builder 39 | VERBOSE=$(VERBOSE) ./tests/run_tests.sh pwsh; 40 | 41 | # Run all shell tests 42 | tests_integration: builder test_unit test_noinit test_zsh test_bash test_fish test_pwsh 43 | 44 | install: 45 | @echo "Installing dela locally..." 46 | cargo install --path . 47 | 48 | run: 49 | @echo "Running dela..." 50 | cargo run 51 | 52 | # Publish to crates.io 53 | publish: tests tests_integration 54 | @echo "Publishing dela to crates.io" 55 | @if [ -z "$(CARGO_REGISTRY_TOKEN)" ]; then \ 56 | echo "Error: CARGO_REGISTRY_TOKEN is not set. Please add it to your .env file."; \ 57 | exit 1; \ 58 | fi 59 | @cargo publish 60 | 61 | # Print git diff without pager 62 | pdiff: 63 | @git --no-pager diff 64 | 65 | format: 66 | cargo fmt --all 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dela! 2 | 3 | Dela is a lightweight task runner that provides discovery for task definitions in various formats, and lets you execute tasks without specifying the runner while delegating their execution to your existing tools like Make, npm, uv, and others. 4 | 5 | ## Installation 6 | 7 | You can install `dela` from crates.io. The `dela init` command will add itself to your shell and create a `.dela` directory in your home directory. 8 | 9 | ```sh 10 | $ cargo install dela 11 | $ dela init 12 | ``` 13 | 14 | ## Usage 15 | 16 | ### Discovering tasks 17 | The `dela list` command will list all the tasks defined. 18 | 19 | ```sh 20 | $ dela list 21 | ``` 22 | 23 | ### Running tasks 24 | You can invoke a task just by its name from the shell via ``. For example here `build` task is defined in `Makefile` and is invoked directly. 25 | 26 | ```sh 27 | $ build 28 | ``` 29 | 30 | If you are running `dela` in a directory for the first time, it will ask you to put the task or the task definition file or the directory itself on the allowed list. This is because you might want to run `dela` in non fully trusted directories and cause inadvertent execution. 31 | 32 | ```sh 33 | $ build 34 | Running build from ~/Projects/dela/Makefile for the first time. Allow? 35 | 0) Allow one time 36 | 1) Allow build from ~/Projects/dela/Makefile 37 | 2) Allow any command from ~/Projects/dela/Makefile 38 | 3) Allow any command from ~/Projects/dela 39 | 4) Deny 40 | ``` 41 | 42 | You can also call request dela explicitly with `dr `. 43 | 44 | ```sh 45 | $ dr build 46 | ``` 47 | 48 | If you don't have dela shell integration, you can use `dela run ` to run a task. This will execute the task in a subshell environment. 49 | 50 | ```sh 51 | $ dela run build 52 | ``` 53 | 54 | ## Frequently Asked Questions 55 | 56 | ### How does dela work? 57 | 58 | `dela` uses your shell's command_not_found_handler to detect when you are trying to run a command that doesn't exist. It then scans the current working directory for task definition files and executes the appropriate task runner. 59 | 60 | ### What happens if a task shares the same name with a command? 61 | 62 | Then the bare command will be executed instead of the task. To execute the task, you can use `dr ` to bypass the shadowed command but still make use of `dela`'s task runner disambiguation. 63 | 64 | ### How do I add a new task? 65 | 66 | You can add a new task by adding a new task definition file. The task definition file can be a Makefile, a pyproject.toml, or a package.json. 67 | 68 | ### What shell environment are tasks executed in? 69 | 70 | When executing bare tasks or via `dr`, tasks are executed in the current shell environment. When running tasks via `dela run`, tasks are executed in a subshell environment. 71 | 72 | ### Which shell integrations are supported? 73 | 74 | Currently, `dela` supports zsh, bash, fish, and PowerShell. 75 | 76 | ### Which task runners are supported? 77 | 78 | Currently, `dela` supports Make, npm, uv, poetry, Maven, Gradle, and Github Actions. 79 | 80 | ### Which platforms are supported? 81 | 82 | Currently, `dela` supports macOS and Linux. 83 | 84 | ### Is dela production ready? 85 | 86 | `dela` is not at 0.1 yet and its cli is subject to change. 87 | 88 | ## Development 89 | 90 | To use a dev version of the rust binary locally, build and install it with the following command. 91 | 92 | ```sh 93 | $ cargo install --path . 94 | ``` 95 | 96 | You can also source the shell integration directly from the `resources` directory. 97 | 98 | ```sh 99 | $ source resources/zsh.sh 100 | ``` 101 | 102 | ## Testing 103 | Run integration tests with `dr test`, it requires `Make`, `cargo`, and `dela` to be installed. 104 | 105 | ```sh 106 | $ tests 107 | ``` 108 | 109 | Run integrations test with `test_shells`, it requires `Make`, `Docker`, and `dela` to be installed. 110 | 111 | ```sh 112 | $ tests_integration 113 | ``` 114 | -------------------------------------------------------------------------------- /dev_docs/architecture.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | This document describes the overall architecture of `dela`—a task runner that automatically delegates tasks to the appropriate definitions across different file types, such as Makefiles, shell scripts, package managers, and more. 4 | 5 | ## Overview 6 | 7 | At a high level, `dela` operates by intercepting commands in your shell that would normally result in a "command not found" error. Instead, `dela` checks to see if the command corresponds to a task in the current directory, and then executes it accordingly. The following sections describe the foundational architecture enabling this functionality. 8 | 9 | --- 10 | 11 | ## Installation & Shell Setup 12 | 13 | 1. **Shell Integration** 14 | - `dela init` modifies the user’s shell configuration (e.g., `.zshrc`, `.bashrc`, etc.) by adding the logic to handle “command not found” events. 15 | - The logic typically appends or modifies the shell’s `command_not_found_handle` function (or equivalent) so that any unrecognized command is forwarded to the `dela` executable. 16 | - The shell configuration will `eval(dela configure_shell)` to configure the shell. This is done so that new versions of dela can be installed without having to re-run the init command. 17 | 18 | 2. **User Home Directory** 19 | - When `dela init` is first run, it creates a `~/.dela` directory to store configuration details, including the allowlists. 20 | 21 | 3. **Cross-Shell Compatibility** 22 | - While `.zshrc` is our primary target, the installation logic may be extended to handle `~/.bashrc`, `~/.config/fish/config.fish`, or other shell init files if detected or requested. 23 | 24 | --- 25 | 26 | ## Task Discovery and Parsing 27 | 28 | 1. **Supported File Types** 29 | - `dela` recognizes tasks in Makefiles (`make` commands), shell scripts (direct executables), Python (`pyproject.toml` scripts), Node (`package.json` scripts), and more. It can be extended to handle further file types by implementing additional parsers. 30 | 31 | 2. **Search Strategy** 32 | - Upon detecting an unrecognized command, `dela` searches the current working directory (and potentially subdirectories) for known task definition files. 33 | - Each file type has a parser that extracts a list of defined tasks (e.g., target names from a Makefile, script entries from a package.json, etc.). 34 | 35 | 3. **Task Mapping** 36 | - The discovered tasks are stored in memory. If a requested task matches multiple definition files, the user is prompted to choose which file or which specific task definition to run. 37 | 38 | --- 39 | 40 | ## Execution Model 41 | 42 | 1. **Shell Invocation** 43 | - Once a matching task is found, `dela` instructs the shell to run the appropriate command. For example, if the task is in a Makefile, it runs `make `. For a Node-based task, it might run `npm run `, and so on. 44 | 45 | 2. **Bare vs. `dr`** 46 | - Users can invoke tasks by calling the bare command without a tool eg (`build`), which triggers the "command not found" handler. Or they can use `dr build`, which bypasses the shell's "command not found" mechanism and directly invokes the runner logic. 47 | 48 | 3. **Shell Execution Strategy** 49 | - Rather than executing commands directly from Rust, `dela` returns commands to the shell for execution. 50 | - This is implemented through shell function wrappers: 51 | ```zsh 52 | dela() { 53 | if [[ $1 == "run" ]]; then 54 | cmd=$(dela get_command "${@:2}") 55 | eval "$cmd" 56 | else 57 | command dela "$@" 58 | fi 59 | } 60 | ``` 61 | - Benefits: 62 | - Commands execute in the actual shell environment 63 | - Shell builtins (cd, source, etc.) work correctly 64 | - Environment modifications persist 65 | - Shell aliases and functions are available 66 | 67 | 4. **Extensibility** 68 | - The architecture supports adding new task definition modules for various technologies. Each module implements a function to detect tasks and a strategy to execute them. These extensions are simply additional rust files in parsers directory. 69 | 70 | --- 71 | 72 | ## Allowlist Management & Security 73 | 74 | 1. **Allowlists** 75 | - `dela` tracks user-approved tasks or task definition files in `~/.dela/allowlists`. This ensures that tasks cannot execute code from an untrusted directory or file without explicit permission when a user typos a command. 76 | 77 | 2. **Prompting** 78 | - When a task is run for the first time from a new directory or new file, the user is prompted to allow or deny that command. This approach helps prevent accidental or malicious commands from executing automatically. 79 | 80 | 3. **Scoping** 81 | - The user can allow just a single run, allow all tasks from a file, or allow any command from an entire directory. Each choice is recorded in the respective allowlist configuration file for future sessions. 82 | 83 | --- 84 | 85 | ## Implementation Details 86 | 87 | 1. **Rust-Based CLI** 88 | - `dela` is written in Rust to maximize portability and performance. It uses libraries for command-line argument parsing and for orchestrating shell calls (e.g. `std::process::Command`). 89 | 90 | 2. **Storage & Configuration** 91 | - All user-specific data (allowlists, configuration, logs, etc.) are stored in the `~/.dela` folder by default. 92 | - A minimal in-memory store is built at runtime from these local files so that repeated tasks in the same session don’t require repeated file access. 93 | 94 | 3. **Error Handling & Logging** 95 | - If a task fails to execute or if a file is unreadable, `dela` provides user-friendly error messages. 96 | - Future improvements might include structured logging for better debugging and analytics. 97 | 98 | --- 99 | 100 | ## Integration Tests 101 | 102 | Dela closely integrates with the shell it is running in. That means that it needs to be 103 | tested in the context of the shell. This is done via Dockerized shell tests. 104 | 105 | The tests for `zsh`, `bash`, `fish`, and `pwsh` tests can be run via `make tests_integration`. These tests should cover shell impacting functionality including running `dela init`, have the `/resources` sourced into the shell, bare executions, `dr` executions, and allowlists. These tests should have direct equivalents between the 4 supported shells. 106 | 107 | Additionally there are integration tests called `noinit` that test dela functionality that doesn't require a shell integration, but does actually read the definition files. These can exercise the `dela` commands without mocking the file system. These tests should cover the `dela list`, `dela get-command`, `della allow-list` and other commands. 108 | 109 | Further there are `unit` integration tests which simply runs the regular rust unit tests inside of docker, which can expose some timing issues. 110 | 111 | These can be run individually via `make test_unit` and `make test_zsh` etc, as well as all together `make tests_integration`. 112 | 113 | --- 114 | 115 | ## Future Enhancements 116 | 117 | ## Dockerized Testing for Shell Scripts and Shell Integration 118 | While unit tests suffice for Rust logic, shell integration tests often require multiple real shells. A recommended approach is: 119 | 1. Create lightweight Docker images that contain different shells (e.g., zsh, bash, fish). 120 | 2. Copy (or mount) the `dela` binary and associated resources (like `zsh.sh`) into each container. 121 | 3. Run scenario-based tests that: 122 | - Initialize a fresh user environment (HOME, SHELL, etc.). 123 | - Execute `dela init`. 124 | - Source the updated shell configuration (e.g., `.zshrc`, `.bashrc`, or `config.fish`). 125 | - Confirm that tasks can be executed directly (bare command) and via `dela run `. 126 | 4. Collect results for each shell to ensure cross-shell functionality remains consistent. 127 | 128 | 1. **Extending to More Task Runners** 129 | - Additional detection and execution for other popular build or scripting tools (Gradle, Maven, Rake, etc.). 130 | 131 | 2. **Plugin Architecture** 132 | - Third-party developers can create plugins for `dela` to support specialized or less common build tools. This might involve a well-defined interface for discovering tasks and executing them. 133 | 134 | 3. **Graphical Shell Completions** 135 | - Command auto-completion in Bash, Zsh, or Fish for discovered tasks, making it easier to see available tasks at a glance. 136 | 137 | 4. **Remote or Distributed Task Execution** 138 | - Potentially allow tasks to be executed in containers or remote servers for more advanced workflows. 139 | 140 | --- 141 | 142 | ## Conclusion 143 | 144 | By combining shell integration, a modular parsing approach, and secure allowlists, `dela` provides a streamlined solution for discovering and running tasks in any directory. The Rust-based CLI foundation ensures easy installation, high performance, and the flexibility to expand into new ecosystems. -------------------------------------------------------------------------------- /dev_docs/style.md: -------------------------------------------------------------------------------- 1 | # Project Style 2 | 3 | This project is extensively developed with llms, and requires affordances for humans and llms to be explicitly written into the repository. This means context is written into .md files for long term knowledge across multiple llm chat sessions. 4 | 5 | This project uses Makefiles as a task runner for building, testing, and formatting and keeping other functionality in one place. 6 | 7 | The folder organization prefers flatness over deep nesting. 8 | 9 | You are often going to be generating code that is not complete. Leave TODOs and reference the DTKT that will complete the task from project_plan.md. Create new DTKT task if necessary. 10 | 11 | When adding new dependencies, show or run the command for the package manger to install the dependency rather than modifying the dependencies definitions directly. 12 | 13 | Implementation for individual cli subcommands should be in the src/commands/ subfolder. 14 | 15 | When writing tests that mock files, always set them to `#[serial]`. 16 | -------------------------------------------------------------------------------- /resources/bash.sh: -------------------------------------------------------------------------------- 1 | # dr function to handle task execution 2 | dr() { 3 | local cmd=$(command dela get-command -- "$@") 4 | eval "$cmd" 5 | } 6 | 7 | # Command not found handler for bash 8 | command_not_found_handle() { 9 | # Skip if we're already running a task to avoid infinite recursion 10 | if [ -n "${DELA_TASK_RUNNING}" ]; then 11 | echo "bash: command not found: $1" >&2 12 | return 127 13 | fi 14 | 15 | # First check if the task is allowed 16 | if ! dela allow-command "$1"; then 17 | return 127 18 | fi 19 | 20 | # If allowed, get and execute the command 21 | if cmd=$(dela get-command -- "$@"); then 22 | export DELA_TASK_RUNNING=1 23 | eval "$cmd" 24 | local status=$? 25 | unset DELA_TASK_RUNNING 26 | return $status 27 | fi 28 | echo "bash: command not found: $1" >&2 29 | return 127 30 | } -------------------------------------------------------------------------------- /resources/fish.sh: -------------------------------------------------------------------------------- 1 | # dr function to handle task execution 2 | function dr 3 | set -l cmd (command dela get-command -- $argv) 4 | if test $status -eq 0 5 | set -x DELA_TASK_RUNNING 1 6 | eval $cmd 7 | set -e DELA_TASK_RUNNING 8 | return $status 9 | end 10 | end 11 | 12 | # Command not found handler to delegate unknown commands to dela 13 | function fish_command_not_found 14 | # Skip if we're already running a task 15 | if set -q DELA_TASK_RUNNING 16 | echo "fish: Unknown command: $argv[1]" >&2 17 | return 127 18 | end 19 | 20 | # Check if this is a dela task 21 | set -l cmd (dela get-command -- $argv 2>/dev/null) 22 | if test $status -eq 0 23 | # Check if task is allowed - only passing the task name, not the arguments 24 | if not dela allow-command $argv[1] 25 | return 127 26 | end 27 | # Execute the task 28 | set -x DELA_TASK_RUNNING 1 29 | eval $cmd 30 | set -e DELA_TASK_RUNNING 31 | return $status 32 | end 33 | echo "fish: Unknown command: $argv[1]" >&2 34 | return 127 35 | end -------------------------------------------------------------------------------- /resources/pwsh.ps1: -------------------------------------------------------------------------------- 1 | # dr function to handle task execution 2 | function dr { 3 | [CmdletBinding()] 4 | param([Parameter(ValueFromRemainingArguments=$true)][string[]]$Arguments) 5 | 6 | $delaBinary = (Get-Command dela -CommandType Application).Source 7 | $cmd = & $delaBinary get-command -- $Arguments 8 | if ($LASTEXITCODE -eq 0) { 9 | $env:DELA_TASK_RUNNING = 1 10 | try { 11 | if ($cmd -is [array]) { 12 | $cmd = $cmd -join "`n" 13 | } 14 | Invoke-Expression $cmd 15 | } finally { 16 | Remove-Item Env:\DELA_TASK_RUNNING -ErrorAction SilentlyContinue 17 | } 18 | } 19 | } 20 | 21 | # Command not found handler to delegate unknown commands to dela 22 | trap [System.Management.Automation.CommandNotFoundException] { 23 | $cmdName = $_.CategoryInfo.TargetName 24 | 25 | # Skip if we're already running a task 26 | if ($env:DELA_TASK_RUNNING) { 27 | Write-Error "pwsh: command not found: $cmdName" 28 | continue 29 | } 30 | 31 | try { 32 | # First check if the task is allowed (only needs the command name) 33 | if (-not (& dela allow-command $cmdName)) { 34 | continue 35 | } 36 | 37 | # If allowed, get and execute the command with all arguments 38 | $allArgs = @($cmdName) + $_.CategoryInfo.CommandLine.ToString().SubString($cmdName.Length).Trim() -split '\s+' 39 | $allArgs = $allArgs | Where-Object { $_ -ne "" } 40 | 41 | $env:DELA_TASK_RUNNING = 1 42 | try { 43 | $cmd = & dela get-command -- $allArgs 44 | if ($LASTEXITCODE -ne 0) { 45 | Write-Error "pwsh: command not found: $cmdName" 46 | continue 47 | } 48 | 49 | if ($cmd -is [array]) { 50 | $cmd = $cmd -join "`n" 51 | } 52 | Invoke-Expression $cmd 53 | } finally { 54 | Remove-Item Env:\DELA_TASK_RUNNING -ErrorAction SilentlyContinue 55 | } 56 | continue 57 | } catch { 58 | Write-Error "pwsh: command not found: $cmdName" 59 | } 60 | continue 61 | } -------------------------------------------------------------------------------- /resources/zsh.sh: -------------------------------------------------------------------------------- 1 | # dr function to handle task execution 2 | dr() { 3 | cmd=$(command dela get-command -- "$@") 4 | eval "$cmd" 5 | } 6 | 7 | # Command not found handler to delegate unknown commands to dela 8 | command_not_found_handler() { 9 | # First check if the task is allowed 10 | if ! dela allow-command "$1"; then 11 | return 127 12 | fi 13 | 14 | # If allowed, get and execute the command 15 | if cmd=$(dela get-command -- "$@"); then 16 | eval "$cmd" 17 | return $? 18 | fi 19 | echo "zsh: command not found: $1" >&2 20 | return 127 21 | } 22 | -------------------------------------------------------------------------------- /src/allowlist.rs: -------------------------------------------------------------------------------- 1 | use crate::prompt::{self, AllowDecision}; 2 | use crate::types::{AllowScope, Allowlist, AllowlistEntry, Task}; 3 | use std::fs; 4 | use std::path::{Path, PathBuf}; 5 | 6 | /// Returns the path to ~/.dela/allowlist.toml 7 | fn allowlist_path() -> Result { 8 | let home = 9 | std::env::var("HOME").map_err(|_| "HOME environment variable not set".to_string())?; 10 | Ok(PathBuf::from(home).join(".dela").join("allowlist.toml")) 11 | } 12 | 13 | /// Load the allowlist from ~/.dela/allowlist.toml. 14 | /// If the file does not exist, return an empty allowlist. 15 | pub fn load_allowlist() -> Result { 16 | let path = allowlist_path()?; 17 | let dela_dir = path.parent().ok_or("Invalid allowlist path")?; 18 | 19 | // Check if ~/.dela exists 20 | if !dela_dir.exists() { 21 | return Err("Dela is not initialized. Please run 'dela init' first.".to_string()); 22 | } 23 | 24 | // If allowlist file doesn't exist but ~/.dela does, return empty allowlist 25 | if !path.exists() { 26 | return Ok(Allowlist::default()); 27 | } 28 | 29 | let contents = 30 | fs::read_to_string(&path).map_err(|e| format!("Failed to read allowlist file: {}", e))?; 31 | 32 | match toml::from_str::(&contents) { 33 | Ok(allowlist) => Ok(allowlist), 34 | Err(e) => Err(format!("Failed to parse allowlist TOML: {}", e)), 35 | } 36 | } 37 | 38 | /// Save the allowlist to ~/.dela/allowlist.toml 39 | pub fn save_allowlist(allowlist: &Allowlist) -> Result<(), String> { 40 | let path = allowlist_path()?; 41 | 42 | // Create .dela directory if it doesn't exist 43 | if let Some(parent) = path.parent() { 44 | fs::create_dir_all(parent) 45 | .map_err(|e| format!("Failed to create .dela directory: {}", e))?; 46 | } 47 | 48 | let toml = toml::to_string_pretty(&allowlist) 49 | .map_err(|e| format!("Failed to serialize allowlist: {}", e))?; 50 | fs::write(&path, toml).map_err(|e| format!("Failed to create allowlist file: {}", e))?; 51 | Ok(()) 52 | } 53 | 54 | /// Check if two paths match, considering directory scope 55 | fn path_matches(task_path: &Path, allowlist_path: &Path, allow_subdirs: bool) -> bool { 56 | if allow_subdirs { 57 | task_path.starts_with(allowlist_path) 58 | } else { 59 | task_path == allowlist_path 60 | } 61 | } 62 | 63 | /// Check if a given task is allowed, based on the loaded allowlist 64 | /// If the task is not in the allowlist, prompt the user for a decision 65 | pub fn check_task_allowed(task: &Task) -> Result { 66 | // Only proceed with allowlist operations if dela is initialized 67 | let mut allowlist = load_allowlist()?; 68 | 69 | // Check each entry to see if it matches 70 | for entry in &allowlist.entries { 71 | match entry.scope { 72 | AllowScope::Deny => { 73 | if path_matches(&task.file_path, &entry.path, true) { 74 | return Ok(false); 75 | } 76 | } 77 | AllowScope::Directory => { 78 | if path_matches(&task.file_path, &entry.path, true) { 79 | return Ok(true); 80 | } 81 | } 82 | AllowScope::File => { 83 | if path_matches(&task.file_path, &entry.path, false) { 84 | return Ok(true); 85 | } 86 | } 87 | AllowScope::Task => { 88 | if path_matches(&task.file_path, &entry.path, false) { 89 | if let Some(ref tasks) = entry.tasks { 90 | if tasks.contains(&task.name) { 91 | return Ok(true); 92 | } 93 | } 94 | } 95 | } 96 | AllowScope::Once => { 97 | // Once is ephemeral and not stored in the allowlist 98 | continue; 99 | } 100 | } 101 | } 102 | 103 | // If no matching entry found, prompt the user 104 | match prompt::prompt_for_task(task)? { 105 | AllowDecision::Allow(scope) => { 106 | match scope { 107 | AllowScope::Once => { 108 | // Don't persist Once decisions 109 | Ok(true) 110 | } 111 | scope => { 112 | // Create a new allowlist entry 113 | let mut entry = AllowlistEntry { 114 | path: task.file_path.clone(), 115 | scope: scope.clone(), 116 | tasks: None, 117 | }; 118 | 119 | // For Task scope, add the specific task name 120 | if scope == AllowScope::Task { 121 | entry.tasks = Some(vec![task.name.clone()]); 122 | } 123 | 124 | // Add the entry and save 125 | allowlist.entries.push(entry); 126 | save_allowlist(&allowlist)?; 127 | Ok(true) 128 | } 129 | } 130 | } 131 | AllowDecision::Deny => { 132 | // Add a deny entry and save 133 | let entry = AllowlistEntry { 134 | path: task.file_path.clone(), 135 | scope: AllowScope::Deny, 136 | tasks: None, 137 | }; 138 | allowlist.entries.push(entry); 139 | save_allowlist(&allowlist)?; 140 | Ok(false) 141 | } 142 | } 143 | } 144 | 145 | /// Check if a given task is allowed with a specific scope, without prompting 146 | pub fn check_task_allowed_with_scope(task: &Task, scope: AllowScope) -> Result { 147 | // Only proceed with allowlist operations if dela is initialized 148 | let mut allowlist = load_allowlist()?; 149 | 150 | // Create a new allowlist entry 151 | let mut entry = AllowlistEntry { 152 | path: task.file_path.clone(), 153 | scope: scope.clone(), 154 | tasks: None, 155 | }; 156 | 157 | // For Task scope, add the specific task name 158 | if scope == AllowScope::Task { 159 | entry.tasks = Some(vec![task.name.clone()]); 160 | } 161 | 162 | // Add the entry and save 163 | allowlist.entries.push(entry); 164 | save_allowlist(&allowlist)?; 165 | Ok(true) 166 | } 167 | 168 | #[cfg(test)] 169 | mod tests { 170 | use super::*; 171 | use crate::types::{Task, TaskDefinitionType, TaskRunner}; 172 | use serial_test::serial; 173 | use std::env; 174 | use std::fs; 175 | use tempfile::TempDir; 176 | 177 | fn create_test_task(name: &str, file_path: PathBuf) -> Task { 178 | Task { 179 | name: name.to_string(), 180 | file_path, 181 | definition_type: TaskDefinitionType::Makefile, 182 | runner: TaskRunner::Make, 183 | source_name: name.to_string(), 184 | description: None, 185 | shadowed_by: None, 186 | disambiguated_name: None, 187 | } 188 | } 189 | 190 | fn setup_test_env() -> (TempDir, Task) { 191 | let temp_dir = TempDir::new().unwrap(); 192 | env::set_var("HOME", temp_dir.path()); 193 | 194 | // Create ~/.dela directory 195 | fs::create_dir_all(temp_dir.path().join(".dela")) 196 | .expect("Failed to create .dela directory"); 197 | 198 | let task = create_test_task("test-task", PathBuf::from("Makefile")); 199 | 200 | (temp_dir, task) 201 | } 202 | 203 | #[test] 204 | #[serial] 205 | fn test_empty_allowlist() { 206 | let (_temp_dir, _task) = setup_test_env(); 207 | let allowlist = load_allowlist().unwrap(); 208 | assert!(allowlist.entries.is_empty()); 209 | } 210 | 211 | #[test] 212 | #[serial] 213 | fn test_save_and_load_allowlist() { 214 | let (temp_dir, _task) = setup_test_env(); 215 | let mut allowlist = Allowlist::default(); 216 | 217 | let entry = AllowlistEntry { 218 | path: PathBuf::from("Makefile"), 219 | scope: AllowScope::File, 220 | tasks: None, 221 | }; 222 | 223 | allowlist.entries.push(entry); 224 | save_allowlist(&allowlist).unwrap(); 225 | 226 | // Debug output 227 | let path = allowlist_path().unwrap(); 228 | println!("Allowlist path: {}", path.display()); 229 | println!("Allowlist exists: {}", path.exists()); 230 | if path.exists() { 231 | let contents = std::fs::read_to_string(&path).unwrap(); 232 | println!("Allowlist contents: {}", contents); 233 | let loaded_from_file: Allowlist = toml::from_str(&contents).unwrap(); 234 | println!("Loaded from file: {:?}", loaded_from_file); 235 | } 236 | 237 | let loaded = load_allowlist().unwrap(); 238 | println!("Loaded from function: {:?}", loaded); 239 | assert_eq!(loaded.entries.len(), 1); 240 | assert_eq!(loaded.entries[0].scope, AllowScope::File); 241 | 242 | // Keep temp_dir around until the end of the test 243 | drop(temp_dir); 244 | } 245 | 246 | #[test] 247 | #[serial] 248 | fn test_path_matches() { 249 | let base = PathBuf::from("/home/user/project"); 250 | let file = base.join("Makefile"); 251 | let subdir = base.join("subdir").join("Makefile"); 252 | 253 | // Exact file match 254 | assert!(path_matches(&file, &file, false)); 255 | assert!(!path_matches(&subdir, &file, false)); 256 | 257 | // Directory match with subdirs 258 | assert!(path_matches(&file, &base, true)); 259 | assert!(path_matches(&subdir, &base, true)); 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /src/builtins.rs: -------------------------------------------------------------------------------- 1 | use crate::environment::ENVIRONMENT; 2 | use crate::types::ShadowType; 3 | use std::path::Path; 4 | 5 | /// Check if a name is a shell builtin 6 | pub fn check_shell_builtin(name: &str) -> Option { 7 | // Get current shell 8 | let shell = ENVIRONMENT.lock().unwrap().get_shell()?; 9 | let shell_path = Path::new(&shell); 10 | let shell_name = shell_path.file_name()?.to_str()?; 11 | 12 | match shell_name { 13 | "zsh" => check_zsh_builtin(name), 14 | "bash" => check_bash_builtin(name), 15 | "fish" => check_fish_builtin(name), 16 | "pwsh" => check_pwsh_builtin(name), 17 | _ => None, 18 | } 19 | } 20 | 21 | /// Check if a name is a zsh builtin 22 | fn check_zsh_builtin(name: &str) -> Option { 23 | const ZSH_BUILTINS: &[&str] = &[ 24 | "cd", 25 | "echo", 26 | "pwd", 27 | "export", 28 | "alias", 29 | "bg", 30 | "bindkey", 31 | "builtin", 32 | "command", 33 | "declare", 34 | "dirs", 35 | "disable", 36 | "disown", 37 | "enable", 38 | "eval", 39 | "exec", 40 | "exit", 41 | "fg", 42 | "getopts", 43 | "hash", 44 | "jobs", 45 | "kill", 46 | "let", 47 | "local", 48 | "popd", 49 | "print", 50 | "pushd", 51 | "read", 52 | "readonly", 53 | "return", 54 | "set", 55 | "setopt", 56 | "shift", 57 | "source", 58 | "suspend", 59 | "test", 60 | "times", 61 | "trap", 62 | "type", 63 | "typeset", 64 | "ulimit", 65 | "umask", 66 | "unalias", 67 | "unfunction", 68 | "unhash", 69 | "unset", 70 | "unsetopt", 71 | "wait", 72 | "whence", 73 | "where", 74 | "which", 75 | ".", 76 | ":", 77 | "[", 78 | "ls", 79 | "test", 80 | ]; 81 | 82 | if ZSH_BUILTINS.contains(&name) { 83 | Some(ShadowType::ShellBuiltin("zsh".to_string())) 84 | } else { 85 | None 86 | } 87 | } 88 | 89 | /// Check if a name is a bash builtin 90 | fn check_bash_builtin(name: &str) -> Option { 91 | const BASH_BUILTINS: &[&str] = &[ 92 | "cd", 93 | "echo", 94 | "pwd", 95 | "export", 96 | "alias", 97 | "bg", 98 | "bind", 99 | "break", 100 | "builtin", 101 | "caller", 102 | "command", 103 | "compgen", 104 | "complete", 105 | "continue", 106 | "declare", 107 | "dirs", 108 | "disown", 109 | "enable", 110 | "eval", 111 | "exec", 112 | "exit", 113 | "fc", 114 | "fg", 115 | "getopts", 116 | "hash", 117 | "help", 118 | "history", 119 | "jobs", 120 | "kill", 121 | "let", 122 | "local", 123 | "logout", 124 | "mapfile", 125 | "popd", 126 | "printf", 127 | "pushd", 128 | "pwd", 129 | "read", 130 | "readarray", 131 | "readonly", 132 | "return", 133 | "set", 134 | "shift", 135 | "shopt", 136 | "source", 137 | "suspend", 138 | "test", 139 | "times", 140 | "trap", 141 | "type", 142 | "typeset", 143 | "ulimit", 144 | "umask", 145 | "unalias", 146 | "unset", 147 | "wait", 148 | ".", 149 | ":", 150 | "[", 151 | "ls", 152 | "test", 153 | ]; 154 | 155 | if BASH_BUILTINS.contains(&name) { 156 | Some(ShadowType::ShellBuiltin("bash".to_string())) 157 | } else { 158 | None 159 | } 160 | } 161 | 162 | /// Check if a name is a fish builtin 163 | fn check_fish_builtin(name: &str) -> Option { 164 | const FISH_BUILTINS: &[&str] = &[ 165 | "cd", 166 | "echo", 167 | "pwd", 168 | "export", 169 | "alias", 170 | "bg", 171 | "bind", 172 | "block", 173 | "breakpoint", 174 | "builtin", 175 | "case", 176 | "command", 177 | "commandline", 178 | "complete", 179 | "contains", 180 | "count", 181 | "dirh", 182 | "dirs", 183 | "disown", 184 | "emit", 185 | "eval", 186 | "exec", 187 | "exit", 188 | "fg", 189 | "fish_config", 190 | "fish_update_completions", 191 | "funced", 192 | "funcsave", 193 | "functions", 194 | "help", 195 | "history", 196 | "isatty", 197 | "jobs", 198 | "math", 199 | "nextd", 200 | "open", 201 | "popd", 202 | "prevd", 203 | "printf", 204 | "pushd", 205 | "pwd", 206 | "random", 207 | "read", 208 | "realpath", 209 | "set", 210 | "set_color", 211 | "source", 212 | "status", 213 | "string", 214 | "test", 215 | "time", 216 | "trap", 217 | "type", 218 | "ulimit", 219 | "umask", 220 | "vared", 221 | ".", 222 | ":", 223 | "[", 224 | "ls", 225 | "test", 226 | ]; 227 | 228 | if FISH_BUILTINS.contains(&name) { 229 | Some(ShadowType::ShellBuiltin("fish".to_string())) 230 | } else { 231 | None 232 | } 233 | } 234 | 235 | /// Check if a name is a PowerShell builtin 236 | fn check_pwsh_builtin(name: &str) -> Option { 237 | #[rustfmt::skip] 238 | const PWSH_BUILTINS: &[&str] = &[ 239 | "cd", "echo", "pwd", "export", "alias", "clear", "copy", "del", 240 | "dir", "exit", "get", "help", "history", "kill", "mkdir", "move", 241 | "popd", "pushd", "pwd", "read", "remove", "rename", "set", "start", 242 | "test", "type", "wait", "where", "write", "ls", "rm", "cp", "mv", 243 | "cat", "clear", "sleep", "sort", "tee", "write", 244 | ]; 245 | 246 | if PWSH_BUILTINS.contains(&name) { 247 | Some(ShadowType::ShellBuiltin("pwsh".to_string())) 248 | } else { 249 | None 250 | } 251 | } 252 | 253 | #[cfg(test)] 254 | mod tests { 255 | use super::*; 256 | use crate::environment::{reset_to_real_environment, set_test_environment, TestEnvironment}; 257 | use serial_test::serial; 258 | 259 | #[test] 260 | #[serial] 261 | fn test_check_zsh_builtin() { 262 | set_test_environment(TestEnvironment::new().with_shell("/bin/zsh")); 263 | 264 | // Test common zsh builtins 265 | for builtin in ["cd", "echo", "pwd", "export"] { 266 | let result = check_zsh_builtin(builtin); 267 | assert!(result.is_some(), "Expected {} to be a zsh builtin", builtin); 268 | assert_eq!(result.unwrap(), ShadowType::ShellBuiltin("zsh".to_string())); 269 | } 270 | 271 | // Test non-builtin 272 | assert!(check_zsh_builtin("definitely_not_a_builtin_123").is_none()); 273 | 274 | reset_to_real_environment(); 275 | } 276 | 277 | #[test] 278 | #[serial] 279 | fn test_check_bash_builtin() { 280 | set_test_environment(TestEnvironment::new().with_shell("/bin/bash")); 281 | 282 | // Test common bash builtins 283 | for builtin in ["cd", "echo", "pwd", "export"] { 284 | let result = check_bash_builtin(builtin); 285 | assert!( 286 | result.is_some(), 287 | "Expected {} to be a bash builtin", 288 | builtin 289 | ); 290 | assert_eq!( 291 | result.unwrap(), 292 | ShadowType::ShellBuiltin("bash".to_string()) 293 | ); 294 | } 295 | 296 | // Test non-builtin 297 | assert!(check_bash_builtin("definitely_not_a_builtin_123").is_none()); 298 | 299 | reset_to_real_environment(); 300 | } 301 | 302 | #[test] 303 | #[serial] 304 | fn test_check_fish_builtin() { 305 | set_test_environment(TestEnvironment::new().with_shell("/usr/bin/fish")); 306 | 307 | // Test common fish builtins 308 | for builtin in ["cd", "echo", "pwd", "set"] { 309 | let result = check_fish_builtin(builtin); 310 | assert!( 311 | result.is_some(), 312 | "Expected {} to be a fish builtin", 313 | builtin 314 | ); 315 | assert_eq!( 316 | result.unwrap(), 317 | ShadowType::ShellBuiltin("fish".to_string()) 318 | ); 319 | } 320 | 321 | // Test non-builtin 322 | assert!(check_fish_builtin("definitely_not_a_builtin_123").is_none()); 323 | 324 | reset_to_real_environment(); 325 | } 326 | 327 | #[test] 328 | #[serial] 329 | fn test_check_pwsh_builtin() { 330 | set_test_environment(TestEnvironment::new().with_shell("/usr/bin/pwsh")); 331 | 332 | // Test common PowerShell builtins 333 | for builtin in ["cd", "echo", "pwd", "get"] { 334 | let result = check_pwsh_builtin(builtin); 335 | assert!( 336 | result.is_some(), 337 | "Expected {} to be a PowerShell builtin", 338 | builtin 339 | ); 340 | assert_eq!( 341 | result.unwrap(), 342 | ShadowType::ShellBuiltin("pwsh".to_string()) 343 | ); 344 | } 345 | 346 | // Test non-builtin 347 | assert!(check_pwsh_builtin("definitely_not_a_builtin_123").is_none()); 348 | 349 | reset_to_real_environment(); 350 | } 351 | 352 | #[test] 353 | #[serial] 354 | fn test_check_shell_builtin() { 355 | // Test with zsh 356 | set_test_environment(TestEnvironment::new().with_shell("/bin/zsh")); 357 | assert!(matches!( 358 | check_shell_builtin("cd"), 359 | Some(ShadowType::ShellBuiltin(shell)) if shell == "zsh" 360 | )); 361 | reset_to_real_environment(); 362 | 363 | // Test with bash 364 | set_test_environment(TestEnvironment::new().with_shell("/bin/bash")); 365 | assert!(matches!( 366 | check_shell_builtin("cd"), 367 | Some(ShadowType::ShellBuiltin(shell)) if shell == "bash" 368 | )); 369 | reset_to_real_environment(); 370 | 371 | // Test with fish 372 | set_test_environment(TestEnvironment::new().with_shell("/usr/bin/fish")); 373 | assert!(matches!( 374 | check_shell_builtin("cd"), 375 | Some(ShadowType::ShellBuiltin(shell)) if shell == "fish" 376 | )); 377 | reset_to_real_environment(); 378 | 379 | // Test with pwsh 380 | set_test_environment(TestEnvironment::new().with_shell("/usr/bin/pwsh")); 381 | assert!(matches!( 382 | check_shell_builtin("cd"), 383 | Some(ShadowType::ShellBuiltin(shell)) if shell == "pwsh" 384 | )); 385 | reset_to_real_environment(); 386 | 387 | // Test with unknown shell 388 | set_test_environment(TestEnvironment::new().with_shell("/bin/unknown_shell")); 389 | assert!(check_shell_builtin("cd").is_none()); 390 | reset_to_real_environment(); 391 | } 392 | } 393 | -------------------------------------------------------------------------------- /src/commands/configure_shell.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | const ZSH_CONFIG: &str = include_str!("../../resources/zsh.sh"); 4 | const BASH_CONFIG: &str = include_str!("../../resources/bash.sh"); 5 | const FISH_CONFIG: &str = include_str!("../../resources/fish.sh"); 6 | const PWSH_CONFIG: &str = include_str!("../../resources/pwsh.ps1"); 7 | 8 | #[derive(Debug, PartialEq)] 9 | enum Shell { 10 | Zsh, 11 | Bash, 12 | Fish, 13 | Pwsh, 14 | Unknown(String), 15 | } 16 | 17 | impl Shell { 18 | fn from_path(path: &str) -> Result { 19 | let shell_path = std::path::PathBuf::from(path); 20 | let shell_name = shell_path 21 | .file_name() 22 | .and_then(|name| name.to_str()) 23 | .ok_or_else(|| "Invalid shell path".to_string())?; 24 | 25 | match shell_name { 26 | "zsh" => Ok(Shell::Zsh), 27 | "bash" => Ok(Shell::Bash), 28 | "fish" => Ok(Shell::Fish), 29 | "pwsh" => Ok(Shell::Pwsh), 30 | name => Ok(Shell::Unknown(name.to_string())), 31 | } 32 | } 33 | } 34 | 35 | pub fn execute() -> Result<(), String> { 36 | // Get the current shell from SHELL environment variable 37 | let shell = env::var("SHELL").map_err(|_| "SHELL environment variable not set".to_string())?; 38 | 39 | // Parse the shell type 40 | let shell_type = Shell::from_path(&shell)?; 41 | 42 | // Handle each shell type 43 | match shell_type { 44 | Shell::Zsh => { 45 | print!("{}", ZSH_CONFIG); 46 | Ok(()) 47 | } 48 | Shell::Bash => { 49 | print!("{}", BASH_CONFIG); 50 | Ok(()) 51 | } 52 | Shell::Fish => { 53 | print!("{}", FISH_CONFIG); 54 | Ok(()) 55 | } 56 | Shell::Pwsh => { 57 | print!("{}", PWSH_CONFIG); 58 | Ok(()) 59 | } 60 | Shell::Unknown(name) => Err(format!("Unsupported shell: {}", name)), 61 | } 62 | } 63 | 64 | #[cfg(test)] 65 | mod tests { 66 | use super::*; 67 | use serial_test::serial; 68 | 69 | fn setup_test_env(shell: &str) { 70 | env::remove_var("SHELL"); 71 | env::set_var("SHELL", shell); 72 | } 73 | 74 | #[test] 75 | #[serial] 76 | fn test_zsh_shell() { 77 | setup_test_env("/bin/zsh"); 78 | let result = execute(); 79 | assert!(result.is_ok()); 80 | } 81 | 82 | #[test] 83 | #[serial] 84 | fn test_bash_shell() { 85 | setup_test_env("/bin/bash"); 86 | let result = execute(); 87 | assert!(result.is_ok()); 88 | } 89 | 90 | #[test] 91 | #[serial] 92 | fn test_fish_shell() { 93 | setup_test_env("/usr/local/bin/fish"); 94 | let result = execute(); 95 | assert!(result.is_ok()); 96 | } 97 | 98 | #[test] 99 | #[serial] 100 | fn test_pwsh_shell() { 101 | setup_test_env("/usr/bin/pwsh"); 102 | let result = execute(); 103 | assert!(result.is_ok()); 104 | } 105 | 106 | #[test] 107 | #[serial] 108 | fn test_unknown_shell() { 109 | setup_test_env("/bin/unknown"); 110 | let result = execute(); 111 | assert!(result.is_err()); 112 | assert_eq!(result.unwrap_err(), "Unsupported shell: unknown"); 113 | } 114 | 115 | #[test] 116 | #[serial] 117 | fn test_invalid_shell_path() { 118 | setup_test_env(""); 119 | let result = execute(); 120 | assert!(result.is_err()); 121 | assert_eq!(result.unwrap_err(), "Invalid shell path"); 122 | } 123 | 124 | #[test] 125 | #[serial] 126 | fn test_missing_shell_env() { 127 | env::remove_var("SHELL"); 128 | let result = execute(); 129 | assert!(result.is_err()); 130 | assert_eq!(result.unwrap_err(), "SHELL environment variable not set"); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/commands/get_command.rs: -------------------------------------------------------------------------------- 1 | use crate::runner::is_runner_available; 2 | use crate::task_discovery; 3 | use std::env; 4 | 5 | pub fn execute(task_with_args: &str) -> Result<(), String> { 6 | let mut parts = task_with_args.split_whitespace(); 7 | let task_name = parts 8 | .next() 9 | .ok_or_else(|| "No task name provided".to_string())?; 10 | let args: Vec<&str> = parts.collect(); 11 | 12 | let current_dir = 13 | env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?; 14 | let discovered = task_discovery::discover_tasks(¤t_dir); 15 | 16 | // Find all tasks with the given name (both original and disambiguated) 17 | let matching_tasks = task_discovery::get_matching_tasks(&discovered, task_name); 18 | 19 | match matching_tasks.len() { 20 | 0 => Err(format!("dela: command or task not found: {}", task_name)), 21 | 1 => { 22 | // Single task found, check if runner is available 23 | let task = matching_tasks[0]; 24 | if !is_runner_available(&task.runner) { 25 | return Err(format!("Runner '{}' not found", task.runner.short_name())); 26 | } 27 | let mut command = task.runner.get_command(task); 28 | if !args.is_empty() { 29 | command.push(' '); 30 | command.push_str(&args.join(" ")); 31 | } 32 | println!("{}", command); 33 | Ok(()) 34 | } 35 | _ => { 36 | // Multiple matches (should not happen with get_matching_tasks, but handle for safety) 37 | let error_msg = task_discovery::format_ambiguous_task_error(task_name, &matching_tasks); 38 | Err(error_msg) 39 | } 40 | } 41 | } 42 | 43 | #[cfg(test)] 44 | mod tests { 45 | use super::*; 46 | use crate::environment::{reset_to_real_environment, set_test_environment, TestEnvironment}; 47 | use crate::task_shadowing::{enable_mock, reset_mock}; 48 | use serial_test::serial; 49 | use std::fs::{self, File}; 50 | use std::io::Write; 51 | use tempfile::TempDir; 52 | 53 | fn setup_test_env() -> (TempDir, TempDir) { 54 | // Create a temp dir for the project 55 | let project_dir = TempDir::new().expect("Failed to create temp directory"); 56 | 57 | // Create a test Makefile 58 | let makefile_content = " 59 | build: ## Building the project 60 | \t@echo Building... 61 | 62 | test: ## Running tests 63 | \t@echo Testing... 64 | "; 65 | let mut makefile = 66 | File::create(project_dir.path().join("Makefile")).expect("Failed to create Makefile"); 67 | makefile 68 | .write_all(makefile_content.as_bytes()) 69 | .expect("Failed to write Makefile"); 70 | 71 | // Create a temp dir for HOME and set it up 72 | let home_dir = TempDir::new().expect("Failed to create temp HOME directory"); 73 | env::set_var("HOME", home_dir.path()); 74 | 75 | // Create ~/.dela directory 76 | fs::create_dir_all(home_dir.path().join(".dela")) 77 | .expect("Failed to create .dela directory"); 78 | 79 | (project_dir, home_dir) 80 | } 81 | 82 | #[test] 83 | #[serial] 84 | fn test_get_command_single_task() { 85 | let (project_dir, home_dir) = setup_test_env(); 86 | env::set_current_dir(&project_dir).expect("Failed to change directory"); 87 | 88 | // Mock make being available 89 | reset_mock(); 90 | enable_mock(); 91 | let env = TestEnvironment::new().with_executable("make"); 92 | set_test_environment(env); 93 | 94 | let result = execute("test"); 95 | assert!(result.is_ok(), "Should succeed for a single task"); 96 | 97 | reset_mock(); 98 | reset_to_real_environment(); 99 | drop(project_dir); 100 | drop(home_dir); 101 | } 102 | 103 | #[test] 104 | #[serial] 105 | fn test_get_command_with_args() { 106 | let (project_dir, home_dir) = setup_test_env(); 107 | env::set_current_dir(&project_dir).expect("Failed to change directory"); 108 | 109 | // Mock make being available 110 | reset_mock(); 111 | enable_mock(); 112 | let env = TestEnvironment::new().with_executable("make"); 113 | set_test_environment(env); 114 | 115 | // Test with the execute function 116 | let result = execute("test --verbose --coverage"); 117 | 118 | // Verify the command was executed successfully 119 | assert!(result.is_ok(), "Should succeed for task with arguments"); 120 | 121 | reset_mock(); 122 | reset_to_real_environment(); 123 | drop(project_dir); 124 | drop(home_dir); 125 | } 126 | 127 | #[test] 128 | #[serial] 129 | fn test_get_command_no_task() { 130 | let (project_dir, home_dir) = setup_test_env(); 131 | env::set_current_dir(&project_dir).expect("Failed to change directory"); 132 | 133 | let result = execute("nonexistent"); 134 | assert!(result.is_err(), "Should fail when no task found"); 135 | assert_eq!( 136 | result.unwrap_err(), 137 | "dela: command or task not found: nonexistent" 138 | ); 139 | 140 | drop(project_dir); 141 | drop(home_dir); 142 | } 143 | 144 | #[test] 145 | #[serial] 146 | fn test_get_command_missing_runner() { 147 | let (project_dir, home_dir) = setup_test_env(); 148 | env::set_current_dir(&project_dir).expect("Failed to change directory"); 149 | 150 | // Set up test environment with no executables to simulate missing make 151 | reset_mock(); 152 | enable_mock(); 153 | let env = TestEnvironment::new(); 154 | set_test_environment(env); 155 | 156 | let result = execute("test"); 157 | assert!(result.is_err(), "Should fail when runner is missing"); 158 | assert_eq!(result.unwrap_err(), "Runner 'make' not found"); 159 | 160 | reset_mock(); 161 | reset_to_real_environment(); 162 | drop(project_dir); 163 | drop(home_dir); 164 | } 165 | 166 | #[test] 167 | #[serial] 168 | fn test_get_command_disambiguated_tasks() { 169 | let (project_dir, home_dir) = setup_test_env(); 170 | env::set_current_dir(&project_dir).expect("Failed to change directory"); 171 | 172 | // Create a package.json with the same task name 173 | let package_json_content = r#"{ 174 | "name": "test-package", 175 | "scripts": { 176 | "test": "jest" 177 | } 178 | }"#; 179 | 180 | File::create(project_dir.path().join("package.json")) 181 | .unwrap() 182 | .write_all(package_json_content.as_bytes()) 183 | .unwrap(); 184 | 185 | // Create package-lock.json to ensure npm is detected 186 | File::create(project_dir.path().join("package-lock.json")) 187 | .unwrap() 188 | .write_all(b"{}") 189 | .unwrap(); 190 | 191 | // Mock both make and npm being available 192 | reset_mock(); 193 | enable_mock(); 194 | let env = TestEnvironment::new() 195 | .with_executable("make") 196 | .with_executable("npm"); 197 | set_test_environment(env); 198 | 199 | // First verify that ambiguous task gives error 200 | let result = execute("test"); 201 | assert!(result.is_err(), "Should fail with ambiguous task name"); 202 | assert!( 203 | result 204 | .unwrap_err() 205 | .contains("Multiple tasks named 'test' found"), 206 | "Error should mention multiple tasks" 207 | ); 208 | 209 | // Verify task lookup for make variant works 210 | let result = execute("test-m"); 211 | assert!( 212 | result.is_ok(), 213 | "Should succeed with disambiguated task name (make)" 214 | ); 215 | 216 | // Verify task lookup for npm variant works 217 | let result = execute("test-n"); 218 | assert!( 219 | result.is_ok(), 220 | "Should succeed with disambiguated task name (npm)" 221 | ); 222 | 223 | // Verify arguments are correctly passed with disambiguated names 224 | let result = execute("test-m --verbose"); 225 | assert!( 226 | result.is_ok(), 227 | "Should succeed with disambiguated task name and args" 228 | ); 229 | 230 | reset_mock(); 231 | reset_to_real_environment(); 232 | drop(project_dir); 233 | drop(home_dir); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/commands/init.rs: -------------------------------------------------------------------------------- 1 | use crate::types::Allowlist; 2 | use std::env; 3 | use std::fs; 4 | use std::io::Write; 5 | use std::path::PathBuf; 6 | 7 | /// Get the current shell name by checking the parent process 8 | fn get_current_shell() -> Result { 9 | // Try to get shell from BASH_VERSION or ZSH_VERSION first 10 | if env::var("BASH_VERSION").is_ok() { 11 | return Ok("bash".to_string()); 12 | } 13 | if env::var("ZSH_VERSION").is_ok() { 14 | return Ok("zsh".to_string()); 15 | } 16 | 17 | // Fallback to $SHELL if version variables aren't set 18 | let shell = env::var("SHELL").map_err(|_| "SHELL environment variable not set".to_string())?; 19 | 20 | let shell_path = std::path::PathBuf::from(&shell); 21 | shell_path 22 | .file_name() 23 | .and_then(|name| name.to_str()) 24 | .map(|s| s.to_string()) 25 | .ok_or_else(|| "Invalid shell path".to_string()) 26 | } 27 | 28 | /// Get the appropriate shell config path based on current shell 29 | fn get_shell_config_path() -> Result { 30 | let shell_name = get_current_shell()?; 31 | let home = env::var("HOME").map_err(|_| "HOME environment variable not set".to_string())?; 32 | let home_path = PathBuf::from(&home); 33 | 34 | match shell_name.as_str() { 35 | "zsh" => Ok(home_path.join(".zshrc")), 36 | "bash" => Ok(home_path.join(".bashrc")), 37 | "fish" => Ok(home_path.join(".config").join("fish").join("config.fish")), 38 | "pwsh" => Ok(home_path 39 | .join(".config") 40 | .join("powershell") 41 | .join("Microsoft.PowerShell_profile.ps1")), 42 | name => Err(format!("Unsupported shell: {}", name)), 43 | } 44 | } 45 | 46 | /// Add dela shell integration to the shell config file 47 | fn add_shell_integration(config_path: &PathBuf) -> Result<(), String> { 48 | // Read the current content 49 | let content = fs::read_to_string(config_path) 50 | .map_err(|e| format!("Failed to read shell config: {}", e))?; 51 | 52 | // Get the shell type from the path 53 | let shell = get_current_shell()?; 54 | 55 | // Check if dela integration is already present, with shell-specific patterns 56 | let integration_pattern = match shell.as_str() { 57 | "fish" => "eval (dela configure-shell | string collect)", 58 | "pwsh" => "Invoke-Expression (dela configure-shell | Out-String)", 59 | _ => "eval \"$(dela configure-shell)\"", 60 | }; 61 | 62 | if content.contains(integration_pattern) { 63 | println!( 64 | "Shell integration already present in {}", 65 | config_path.display() 66 | ); 67 | return Ok(()); 68 | } 69 | 70 | // Create parent directory if it doesn't exist (needed for PowerShell) 71 | if let Some(parent) = config_path.parent() { 72 | if !parent.exists() { 73 | fs::create_dir_all(parent) 74 | .map_err(|e| format!("Failed to create config directory: {}", e))?; 75 | } 76 | } 77 | 78 | // Open file in append mode 79 | let mut file = fs::OpenOptions::new() 80 | .create(true) 81 | .append(true) 82 | .open(config_path) 83 | .map_err(|e| format!("Failed to open shell config: {}", e))?; 84 | 85 | // Add dela integration with shell-specific syntax 86 | writeln!(file).map_err(|e| format!("Failed to write to shell config: {}", e))?; 87 | writeln!(file, "# dela shell integration") 88 | .map_err(|e| format!("Failed to write to shell config: {}", e))?; 89 | writeln!(file, "{}", integration_pattern) 90 | .map_err(|e| format!("Failed to write to shell config: {}", e))?; 91 | 92 | Ok(()) 93 | } 94 | 95 | pub fn execute() -> Result<(), String> { 96 | println!("Initializing dela..."); 97 | 98 | // Get the shell config path first to validate shell support 99 | let config_path = get_shell_config_path()?; 100 | let shell_name = get_current_shell()?; 101 | 102 | println!( 103 | "Detected {} shell, configuring {}", 104 | shell_name, 105 | config_path.display() 106 | ); 107 | 108 | // Create ~/.dela directory if it doesn't exist 109 | let home = env::var("HOME").map_err(|_| "HOME environment variable not set".to_string())?; 110 | let dela_dir = PathBuf::from(&home).join(".dela"); 111 | 112 | if !dela_dir.exists() { 113 | println!( 114 | "Creating dela configuration directory at {}", 115 | dela_dir.display() 116 | ); 117 | fs::create_dir_all(&dela_dir) 118 | .map_err(|e| format!("Failed to create ~/.dela directory: {}", e))?; 119 | } else { 120 | println!( 121 | "Using existing dela configuration directory at {}", 122 | dela_dir.display() 123 | ); 124 | } 125 | 126 | // Create empty allowlist.toml if it doesn't exist 127 | let allowlist_path = dela_dir.join("allowlist.toml"); 128 | if !allowlist_path.exists() { 129 | println!("Creating empty allowlist at {}", allowlist_path.display()); 130 | let empty_allowlist = Allowlist::default(); 131 | let toml = toml::to_string_pretty(&empty_allowlist) 132 | .map_err(|e| format!("Failed to serialize empty allowlist: {}", e))?; 133 | fs::write(&allowlist_path, toml) 134 | .map_err(|e| format!("Failed to create allowlist file: {}", e))?; 135 | } 136 | 137 | // Add shell integration 138 | println!("Adding shell integration to {}", config_path.display()); 139 | add_shell_integration(&config_path)?; 140 | 141 | println!("\nInitialization complete! To activate dela, either:"); 142 | println!("1. Restart your shell"); 143 | println!("2. Run: source {}", config_path.display()); 144 | 145 | Ok(()) 146 | } 147 | 148 | #[cfg(test)] 149 | mod tests { 150 | use super::*; 151 | use serial_test::serial; 152 | use tempfile::TempDir; 153 | 154 | fn setup_test_env(shell: &str, home: &PathBuf) -> Result<(), std::io::Error> { 155 | env::set_var("SHELL", shell); 156 | env::set_var("HOME", home.to_str().unwrap()); 157 | Ok(()) 158 | } 159 | 160 | #[test] 161 | #[serial] 162 | fn test_init_zsh() { 163 | let temp_dir = TempDir::new().unwrap(); 164 | let home = temp_dir.path().to_path_buf(); 165 | setup_test_env("/bin/zsh", &home).unwrap(); 166 | 167 | // Create a minimal .zshrc 168 | let zshrc = home.join(".zshrc"); 169 | fs::write(&zshrc, "# existing zsh config\n").unwrap(); 170 | 171 | let result = execute(); 172 | assert!(result.is_ok()); 173 | 174 | // Verify the content 175 | let content = fs::read_to_string(&zshrc).unwrap(); 176 | assert!(content.contains("eval \"$(dela configure-shell)\"")); 177 | } 178 | 179 | #[test] 180 | #[serial] 181 | fn test_init_with_existing_integration() { 182 | let temp_dir = TempDir::new().unwrap(); 183 | let home = temp_dir.path().to_path_buf(); 184 | setup_test_env("/bin/zsh", &home).unwrap(); 185 | 186 | // Create .zshrc with existing integration 187 | let zshrc = home.join(".zshrc"); 188 | fs::write( 189 | &zshrc, 190 | "# existing config\neval \"$(dela configure-shell)\"\n", 191 | ) 192 | .unwrap(); 193 | 194 | let result = execute(); 195 | assert!(result.is_ok()); 196 | 197 | // Verify no duplicate integration was added 198 | let content = fs::read_to_string(&zshrc).unwrap(); 199 | assert_eq!( 200 | content.matches("eval \"$(dela configure-shell)\"").count(), 201 | 1 202 | ); 203 | } 204 | 205 | #[test] 206 | #[serial] 207 | fn test_init_creates_dela_dir() { 208 | let temp_dir = TempDir::new().unwrap(); 209 | let home = temp_dir.path().to_path_buf(); 210 | setup_test_env("/bin/zsh", &home).unwrap(); 211 | 212 | // Create a minimal .zshrc 213 | let zshrc = home.join(".zshrc"); 214 | fs::write(&zshrc, "# existing zsh config\n").unwrap(); 215 | 216 | let result = execute(); 217 | assert!(result.is_ok()); 218 | 219 | // Verify ~/.dela was created 220 | let dela_dir = home.join(".dela"); 221 | assert!(dela_dir.exists()); 222 | assert!(dela_dir.is_dir()); 223 | } 224 | 225 | #[test] 226 | #[serial] 227 | fn test_init_unsupported_shell() { 228 | let temp_dir = TempDir::new().unwrap(); 229 | let home = temp_dir.path().to_path_buf(); 230 | setup_test_env("/bin/unsupported", &home).unwrap(); 231 | 232 | let result = execute(); 233 | assert!(result.is_err()); 234 | assert_eq!(result.unwrap_err(), "Unsupported shell: unsupported"); 235 | } 236 | 237 | #[test] 238 | #[serial] 239 | fn test_init_fish() { 240 | let temp_dir = TempDir::new().unwrap(); 241 | let home = temp_dir.path().to_path_buf(); 242 | setup_test_env("/usr/bin/fish", &home).unwrap(); 243 | 244 | // Create fish config directory and minimal config.fish 245 | let fish_config_dir = home.join(".config").join("fish"); 246 | fs::create_dir_all(&fish_config_dir).unwrap(); 247 | let config_fish = fish_config_dir.join("config.fish"); 248 | fs::write(&config_fish, "# existing fish config\n").unwrap(); 249 | 250 | let result = execute(); 251 | assert!(result.is_ok()); 252 | 253 | // Verify the content has the fish-specific integration pattern 254 | let content = fs::read_to_string(&config_fish).unwrap(); 255 | assert!(content.contains("eval (dela configure-shell | string collect)")); 256 | } 257 | 258 | #[test] 259 | #[serial] 260 | fn test_init_pwsh() { 261 | let temp_dir = TempDir::new().unwrap(); 262 | let home = temp_dir.path().to_path_buf(); 263 | setup_test_env("/bin/pwsh", &home).unwrap(); 264 | 265 | // Create PowerShell config directory and minimal profile 266 | let pwsh_config_dir = home.join(".config").join("powershell"); 267 | fs::create_dir_all(&pwsh_config_dir).unwrap(); 268 | let config_pwsh = pwsh_config_dir.join("Microsoft.PowerShell_profile.ps1"); 269 | fs::write(&config_pwsh, "# existing PowerShell config\n").unwrap(); 270 | 271 | let result = execute(); 272 | assert!(result.is_ok()); 273 | 274 | // Verify the content has the PowerShell-specific integration pattern 275 | let content = fs::read_to_string(&config_pwsh).unwrap(); 276 | assert!(content.contains("Invoke-Expression (dela configure-shell | Out-String)")); 277 | } 278 | 279 | #[test] 280 | #[serial] 281 | fn test_init_creates_allowlist() { 282 | let temp_dir = TempDir::new().unwrap(); 283 | let home = temp_dir.path().to_path_buf(); 284 | setup_test_env("/bin/zsh", &home).unwrap(); 285 | 286 | // Create a minimal .zshrc 287 | let zshrc = home.join(".zshrc"); 288 | fs::write(&zshrc, "# existing zsh config\n").unwrap(); 289 | 290 | let result = execute(); 291 | assert!(result.is_ok()); 292 | 293 | // Verify allowlist.toml was created 294 | let allowlist_path = home.join(".dela").join("allowlist.toml"); 295 | assert!(allowlist_path.exists()); 296 | assert!(allowlist_path.is_file()); 297 | 298 | // Verify it contains valid TOML for an empty allowlist 299 | let content = fs::read_to_string(&allowlist_path).unwrap(); 300 | let allowlist: Allowlist = toml::from_str(&content).unwrap(); 301 | assert!(allowlist.entries.is_empty()); 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod allow_command; 2 | pub mod configure_shell; 3 | pub mod get_command; 4 | pub mod init; 5 | pub mod list; 6 | pub mod run; 7 | pub mod run_command; 8 | -------------------------------------------------------------------------------- /src/commands/run.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::run_command; 2 | 3 | pub fn execute(task_name: &str) -> Result<(), String> { 4 | println!("Note: The 'dela run' command is meant to be intercepted by shell integration."); 5 | println!("If you're seeing this message, it means either:"); 6 | println!("1. Shell integration is not installed (run 'dela init' to set it up)"); 7 | println!("2. You're running dela directly instead of through the shell function"); 8 | 9 | // Execute the task directly when shell integration is not detected 10 | run_command::execute(task_name) 11 | } 12 | -------------------------------------------------------------------------------- /src/environment.rs: -------------------------------------------------------------------------------- 1 | use once_cell::sync::Lazy; 2 | use std::collections::HashSet; 3 | use std::sync::Arc; 4 | use std::sync::Mutex; 5 | 6 | /// Abstraction for environment interactions 7 | pub trait Environment: Send + Sync { 8 | fn get_shell(&self) -> Option; 9 | fn check_executable(&self, name: &str) -> Option; 10 | } 11 | 12 | /// Production environment implementation 13 | pub struct RealEnvironment; 14 | 15 | impl Environment for RealEnvironment { 16 | fn get_shell(&self) -> Option { 17 | std::env::var("SHELL").ok() 18 | } 19 | 20 | fn check_executable(&self, name: &str) -> Option { 21 | use std::process::Command; 22 | let output = Command::new("which").arg(name).output().ok()?; 23 | if output.status.success() { 24 | Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) 25 | } else { 26 | None 27 | } 28 | } 29 | } 30 | 31 | /// Test environment implementation 32 | #[derive(Default, Clone)] 33 | pub struct TestEnvironment { 34 | shell: Option, 35 | executables: HashSet, 36 | } 37 | 38 | #[cfg(test)] 39 | impl TestEnvironment { 40 | pub fn new() -> Self { 41 | Self::default() 42 | } 43 | 44 | pub fn with_shell(mut self, shell: impl Into) -> Self { 45 | self.shell = Some(shell.into()); 46 | self 47 | } 48 | 49 | pub fn with_executable(mut self, name: impl Into) -> Self { 50 | self.executables.insert(name.into()); 51 | self 52 | } 53 | } 54 | 55 | impl Environment for TestEnvironment { 56 | fn get_shell(&self) -> Option { 57 | self.shell.clone() 58 | } 59 | 60 | fn check_executable(&self, name: &str) -> Option { 61 | if self.executables.contains(name) { 62 | Some(format!("/mock/bin/{}", name)) 63 | } else { 64 | None 65 | } 66 | } 67 | } 68 | 69 | /// Global environment instance 70 | pub static ENVIRONMENT: Lazy>> = 71 | Lazy::new(|| Mutex::new(Arc::new(RealEnvironment))); 72 | 73 | /// Helper to set the environment for testing 74 | #[cfg(test)] 75 | pub fn set_test_environment(env: TestEnvironment) { 76 | *ENVIRONMENT.lock().unwrap() = Arc::new(env); 77 | } 78 | 79 | /// Helper to reset to real environment 80 | #[cfg(test)] 81 | pub fn reset_to_real_environment() { 82 | *ENVIRONMENT.lock().unwrap() = Arc::new(RealEnvironment); 83 | } 84 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod allowlist; 2 | pub mod builtins; 3 | pub mod commands; 4 | pub mod environment; 5 | pub mod parsers; 6 | pub mod prompt; 7 | pub mod runner; 8 | pub mod runners; 9 | pub mod task_discovery; 10 | pub mod task_shadowing; 11 | pub mod types; 12 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::{Parser, Subcommand}; 2 | 3 | mod allowlist; 4 | mod builtins; 5 | mod commands; 6 | mod environment; 7 | mod parsers; 8 | mod prompt; 9 | mod runner; 10 | mod runners { 11 | pub mod runners_package_json; 12 | pub mod runners_pyproject_toml; 13 | } 14 | mod task_discovery; 15 | mod task_shadowing; 16 | mod types; 17 | 18 | /// dela - A task runner that delegates to others 19 | #[derive(Parser)] 20 | #[command( 21 | name = "dela", 22 | author = "Alex Yankov", 23 | version, 24 | about = "A task runner that delegates to other runners", 25 | long_about = "Dela scans your project directory for task definitions in various formats (Makefile, package.json, etc.) and lets you run them directly from your shell.\n\nAfter running '$ dela init', you can:\n1. Use '$ dr ' to execute a task directly\n2. Execute task with bare name `$ ` through the shell integration" 26 | )] 27 | struct Cli { 28 | #[command(subcommand)] 29 | command: Commands, 30 | } 31 | 32 | #[derive(Subcommand)] 33 | enum Commands { 34 | /// Initialize dela and configure shell integration 35 | /// 36 | /// This command will: 37 | /// 1. Create ~/.dela directory for configuration 38 | /// 2. Create an initial allowlist.toml 39 | /// 3. Add shell integration to your shell's config file 40 | /// 41 | /// Example: dela init 42 | Init, 43 | 44 | /// Configure shell integration (used internally by init) 45 | /// 46 | /// Outputs shell-specific configuration code that needs to be evaluated. 47 | /// This is typically called by your shell's config file, not directly. 48 | /// 49 | /// Example: eval "$(dela configure-shell)" 50 | #[command(name = "configure-shell")] 51 | ConfigureShell, 52 | 53 | /// List all available tasks in the current directory 54 | /// 55 | /// Shows tasks from Makefiles, package.json scripts, pyproject.toml, and more. 56 | /// Use --verbose for additional details about task sources and runners. 57 | /// 58 | /// Example: dela list 59 | /// Example: dela list --verbose 60 | List { 61 | /// Show detailed information about task definition files 62 | #[arg(short, long)] 63 | verbose: bool, 64 | }, 65 | 66 | /// Run a specific task 67 | /// 68 | /// Note: This command is meant to be used through shell integration. 69 | /// Instead of 'dela run ', use 'dr ' or just ''. 70 | /// 71 | /// Example: dr build 72 | /// Example: build 73 | Run { 74 | /// Name of the task to run 75 | task: String, 76 | }, 77 | 78 | /// Get the shell command for a task (used internally by shell functions) 79 | /// 80 | /// Returns the actual command that would be executed for a task. 81 | /// This is used internally by shell integration and shouldn't be called directly. 82 | /// 83 | /// Example: dela get-command build 84 | #[command(name = "get-command", trailing_var_arg = true)] 85 | GetCommand { 86 | /// Name of the task followed by any arguments to pass to it 87 | args: Vec, 88 | }, 89 | 90 | /// Check if a task is allowed to run (used internally by shell functions) 91 | /// 92 | /// Consults the allowlist at ~/.dela/allowlist.toml to determine if a task can be executed. 93 | /// If the command is not covered by the allowlist, it will prompt the user to allow or deny the command. 94 | /// This is used internally by shell integration and shouldn't be called directly. 95 | /// 96 | /// Example: dela allow-command build 97 | /// Example: dela allow-command build --allow 2 98 | #[command(name = "allow-command")] 99 | AllowCommand { 100 | /// Name of the task to check 101 | task: String, 102 | /// Automatically allow with a specific choice (2-5) 103 | #[arg(long)] 104 | allow: Option, 105 | }, 106 | } 107 | 108 | fn main() { 109 | let cli = Cli::parse(); 110 | 111 | let result = match cli.command { 112 | Commands::Init => commands::init::execute(), 113 | Commands::ConfigureShell => commands::configure_shell::execute(), 114 | Commands::List { verbose } => commands::list::execute(verbose), 115 | Commands::Run { task } => commands::run::execute(&task), 116 | Commands::GetCommand { args } => { 117 | if args.is_empty() { 118 | Err("No task name provided".to_string()) 119 | } else { 120 | commands::get_command::execute(&args.join(" ")) 121 | } 122 | } 123 | Commands::AllowCommand { task, allow } => commands::allow_command::execute(&task, allow), 124 | }; 125 | 126 | if let Err(err) = result { 127 | if err.starts_with("dela: command or task not found") { 128 | eprintln!("{}", err); 129 | } else { 130 | eprintln!("Error: {}", err); 131 | } 132 | std::process::exit(1); 133 | } 134 | } 135 | 136 | #[cfg(test)] 137 | mod tests { 138 | use std::io::Write; 139 | use tempfile::NamedTempFile; 140 | 141 | #[test] 142 | fn test_command_not_found_error() { 143 | // Create a temporary file to capture stderr 144 | let mut stderr_file = NamedTempFile::new().unwrap(); 145 | 146 | // Function to test error handling 147 | let mut handle_error = |err: &str| { 148 | if err.starts_with("dela: command or task not found") { 149 | writeln!(stderr_file, "{}", err).unwrap(); 150 | } else { 151 | writeln!(stderr_file, "Error: {}", err).unwrap(); 152 | } 153 | }; 154 | 155 | // Test command not found error 156 | handle_error("dela: command or task not found: missing_command"); 157 | 158 | // Test regular error 159 | handle_error("Failed to execute task"); 160 | 161 | // Reset file position to beginning for reading 162 | stderr_file.as_file_mut().flush().unwrap(); 163 | let content = std::fs::read_to_string(stderr_file.path()).unwrap(); 164 | 165 | // Check output content 166 | let lines: Vec<&str> = content.lines().collect(); 167 | assert_eq!(lines.len(), 2, "Expected exactly two error lines"); 168 | 169 | // First line should NOT have "Error:" prefix 170 | assert_eq!( 171 | lines[0], "dela: command or task not found: missing_command", 172 | "Command not found error should not have 'Error:' prefix" 173 | ); 174 | 175 | // Second line should have "Error:" prefix 176 | assert_eq!( 177 | lines[1], "Error: Failed to execute task", 178 | "Regular error should have 'Error:' prefix" 179 | ); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/parsers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod parse_github_actions; 2 | pub mod parse_gradle; 3 | pub mod parse_makefile; 4 | pub mod parse_package_json; 5 | pub mod parse_pom_xml; 6 | pub mod parse_pyproject_toml; 7 | pub mod parse_taskfile; 8 | 9 | pub use parse_github_actions::parse as parse_github_actions; 10 | pub use parse_gradle::parse as parse_gradle; 11 | pub use parse_makefile::parse as parse_makefile; 12 | pub use parse_package_json::parse as parse_package_json; 13 | pub use parse_pom_xml::parse as parse_pom_xml; 14 | pub use parse_pyproject_toml::parse as parse_pyproject_toml; 15 | pub use parse_taskfile::parse as parse_taskfile; 16 | -------------------------------------------------------------------------------- /src/parsers/parse_github_actions.rs: -------------------------------------------------------------------------------- 1 | use crate::types::{Task, TaskDefinitionType, TaskRunner}; 2 | use serde_yaml::Value; 3 | use std::fs::File; 4 | use std::io::Read; 5 | use std::path::Path; 6 | 7 | /// Parse GitHub Actions workflow file and extract workflows as tasks 8 | /// 9 | /// This function parses a GitHub Actions workflow file and extracts the entire workflow as a single task. 10 | /// The tasks can be executed using the `act` command-line tool. 11 | pub fn parse(file_path: &Path) -> Result, String> { 12 | let mut file = File::open(file_path).map_err(|e| format!("Failed to open file: {}", e))?; 13 | let mut contents = String::new(); 14 | file.read_to_string(&mut contents) 15 | .map_err(|e| format!("Failed to read file: {}", e))?; 16 | 17 | parse_workflow_string(&contents, file_path) 18 | } 19 | 20 | /// Parse GitHub Actions workflow content from a string 21 | fn parse_workflow_string(content: &str, file_path: &Path) -> Result, String> { 22 | let workflow: Value = serde_yaml::from_str(content) 23 | .map_err(|e| format!("Failed to parse workflow YAML: {}", e))?; 24 | 25 | let workflow_map = match workflow { 26 | Value::Mapping(map) => map, 27 | _ => return Err("Workflow YAML is not a mapping".to_string()), 28 | }; 29 | 30 | // Try to get workflow name for description 31 | let workflow_name = workflow_map 32 | .get(&Value::String("name".to_string())) 33 | .and_then(|v| match v { 34 | Value::String(s) => Some(s.clone()), 35 | _ => None, 36 | }); 37 | 38 | // Extract jobs to confirm the workflow is valid 39 | let jobs = match workflow_map.get(&Value::String("jobs".to_string())) { 40 | Some(Value::Mapping(jobs_map)) => jobs_map, 41 | _ => return Err("No jobs found in workflow file".to_string()), 42 | }; 43 | 44 | if jobs.is_empty() { 45 | return Err("Workflow contains no jobs".to_string()); 46 | } 47 | 48 | // Extract filename without path for task name 49 | let file_name = file_path 50 | .file_name() 51 | .and_then(|n| n.to_str()) 52 | .map(|n| { 53 | if n.ends_with(".yml") || n.ends_with(".yaml") { 54 | &n[0..n.rfind('.').unwrap_or(n.len())] 55 | } else { 56 | n 57 | } 58 | }) 59 | .unwrap_or("workflow"); 60 | 61 | // Create a single task for the entire workflow 62 | // Always use file name as the task name 63 | let task_name = file_name.to_string(); 64 | 65 | let task = Task { 66 | name: task_name.clone(), 67 | file_path: file_path.to_path_buf(), 68 | definition_type: TaskDefinitionType::GitHubActions, 69 | runner: TaskRunner::Act, 70 | source_name: task_name, // Source name is the same as the task name (entire workflow) 71 | description: workflow_name, 72 | shadowed_by: None, 73 | disambiguated_name: None, 74 | }; 75 | 76 | Ok(vec![task]) 77 | } 78 | 79 | #[cfg(test)] 80 | mod tests { 81 | use super::*; 82 | use std::fs; 83 | use std::path::PathBuf; 84 | use tempfile::TempDir; 85 | 86 | fn create_test_workflow(dir: &Path, filename: &str, content: &str) -> PathBuf { 87 | let file_path = dir.join(filename); 88 | fs::write(&file_path, content).expect("Failed to write test workflow file"); 89 | file_path 90 | } 91 | 92 | #[test] 93 | fn test_parse_simple_workflow() { 94 | let temp_dir = TempDir::new().expect("Failed to create temp directory"); 95 | 96 | let workflow_content = r#" 97 | name: CI 98 | on: 99 | push: 100 | branches: [ main ] 101 | pull_request: 102 | branches: [ main ] 103 | 104 | jobs: 105 | build: 106 | runs-on: ubuntu-latest 107 | steps: 108 | - uses: actions/checkout@v3 109 | - name: Build 110 | run: echo "Building..." 111 | 112 | test: 113 | needs: build 114 | runs-on: ubuntu-latest 115 | steps: 116 | - uses: actions/checkout@v3 117 | - name: Test 118 | run: echo "Testing..." 119 | "#; 120 | 121 | let file_path = create_test_workflow(&temp_dir.path(), "workflow.yml", workflow_content); 122 | 123 | let tasks = parse(&file_path).expect("Failed to parse workflow"); 124 | 125 | assert_eq!(tasks.len(), 1, "Should have one task"); 126 | 127 | let task = &tasks[0]; 128 | assert_eq!(task.name, "workflow"); 129 | assert_eq!(task.definition_type, TaskDefinitionType::GitHubActions); 130 | assert_eq!(task.runner, TaskRunner::Act); 131 | assert_eq!(task.source_name, "workflow"); 132 | assert_eq!(task.description, Some("CI".to_string())); 133 | } 134 | 135 | #[test] 136 | fn test_parse_complex_workflow() { 137 | let temp_dir = TempDir::new().expect("Failed to create temp directory"); 138 | 139 | let workflow_content = r#" 140 | name: Complex Workflow 141 | on: 142 | push: 143 | branches: [ main, develop ] 144 | pull_request: 145 | branches: [ main ] 146 | workflow_dispatch: 147 | inputs: 148 | environment: 149 | description: 'Environment to deploy to' 150 | required: true 151 | default: 'staging' 152 | 153 | jobs: 154 | lint: 155 | runs-on: ubuntu-latest 156 | steps: 157 | - uses: actions/checkout@v3 158 | - name: Lint Code 159 | run: echo "Linting..." 160 | 161 | build: 162 | runs-on: ubuntu-latest 163 | needs: lint 164 | strategy: 165 | matrix: 166 | node-version: [14.x, 16.x, 18.x] 167 | os: [ubuntu-latest, windows-latest] 168 | steps: 169 | - uses: actions/checkout@v3 170 | - name: Use Node.js ${{ matrix.node-version }} 171 | uses: actions/setup-node@v3 172 | with: 173 | node-version: ${{ matrix.node-version }} 174 | - name: Build 175 | run: | 176 | npm ci 177 | npm run build 178 | 179 | test: 180 | runs-on: ubuntu-latest 181 | needs: build 182 | steps: 183 | - uses: actions/checkout@v3 184 | - name: Test 185 | run: npm test 186 | 187 | deploy: 188 | if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' 189 | runs-on: ubuntu-latest 190 | needs: [build, test] 191 | environment: ${{ github.event.inputs.environment || 'production' }} 192 | steps: 193 | - uses: actions/checkout@v3 194 | - name: Deploy 195 | run: echo "Deploying to ${{ github.event.inputs.environment || 'production' }}" 196 | "#; 197 | 198 | let file_path = 199 | create_test_workflow(&temp_dir.path(), "complex-workflow.yml", workflow_content); 200 | 201 | let tasks = parse(&file_path).expect("Failed to parse complex workflow"); 202 | 203 | assert_eq!(tasks.len(), 1, "Should have one task"); 204 | 205 | let task = &tasks[0]; 206 | assert_eq!(task.name, "complex-workflow"); 207 | assert_eq!(task.definition_type, TaskDefinitionType::GitHubActions); 208 | assert_eq!(task.runner, TaskRunner::Act); 209 | assert_eq!(task.source_name, "complex-workflow"); 210 | assert_eq!(task.description, Some("Complex Workflow".to_string())); 211 | } 212 | 213 | #[test] 214 | fn test_parse_multiple_workflows() { 215 | let temp_dir = TempDir::new().expect("Failed to create temp directory"); 216 | 217 | // Create .github/workflows directory structure 218 | let workflows_dir = temp_dir.path().join(".github").join("workflows"); 219 | fs::create_dir_all(&workflows_dir).expect("Failed to create workflows directory"); 220 | 221 | // Create first workflow 222 | let ci_workflow = r#" 223 | name: CI 224 | on: [push, pull_request] 225 | jobs: 226 | build: 227 | runs-on: ubuntu-latest 228 | steps: 229 | - uses: actions/checkout@v3 230 | - name: Build 231 | run: echo "Building..." 232 | "#; 233 | let ci_path = workflows_dir.join("ci.yml"); 234 | fs::write(&ci_path, ci_workflow).expect("Failed to write ci workflow"); 235 | 236 | // Create second workflow 237 | let deploy_workflow = r#" 238 | name: Deploy 239 | on: 240 | push: 241 | branches: [main] 242 | jobs: 243 | deploy: 244 | runs-on: ubuntu-latest 245 | steps: 246 | - uses: actions/checkout@v3 247 | - name: Deploy 248 | run: echo "Deploying..." 249 | "#; 250 | let deploy_path = workflows_dir.join("deploy.yml"); 251 | fs::write(&deploy_path, deploy_workflow).expect("Failed to write deploy workflow"); 252 | 253 | // Parse both workflows 254 | let ci_tasks = parse(&ci_path).expect("Failed to parse CI workflow"); 255 | let deploy_tasks = parse(&deploy_path).expect("Failed to parse Deploy workflow"); 256 | 257 | // Verify CI workflow tasks 258 | assert_eq!(ci_tasks.len(), 1); 259 | assert_eq!(ci_tasks[0].name, "ci"); 260 | assert_eq!(ci_tasks[0].description, Some("CI".to_string())); 261 | 262 | // Verify Deploy workflow tasks 263 | assert_eq!(deploy_tasks.len(), 1); 264 | assert_eq!(deploy_tasks[0].name, "deploy"); 265 | assert_eq!(deploy_tasks[0].description, Some("Deploy".to_string())); 266 | } 267 | 268 | #[test] 269 | fn test_parse_workflow_without_name() { 270 | let temp_dir = TempDir::new().expect("Failed to create temp directory"); 271 | 272 | let workflow_content = r#" 273 | on: 274 | push: 275 | branches: [ main ] 276 | 277 | jobs: 278 | build: 279 | runs-on: ubuntu-latest 280 | steps: 281 | - uses: actions/checkout@v3 282 | - name: Build 283 | run: echo "Building..." 284 | "#; 285 | 286 | let file_path = 287 | create_test_workflow(&temp_dir.path(), "unnamed-workflow.yml", workflow_content); 288 | 289 | let tasks = parse(&file_path).expect("Failed to parse workflow without name"); 290 | 291 | assert_eq!(tasks.len(), 1); 292 | assert_eq!(tasks[0].name, "unnamed-workflow"); 293 | assert_eq!(tasks[0].definition_type, TaskDefinitionType::GitHubActions); 294 | assert_eq!(tasks[0].runner, TaskRunner::Act); 295 | assert_eq!( 296 | tasks[0].description, None, 297 | "Description should be None when workflow has no name" 298 | ); 299 | } 300 | 301 | #[test] 302 | fn test_parse_invalid_workflow() { 303 | let temp_dir = TempDir::new().expect("Failed to create temp directory"); 304 | 305 | // Invalid YAML 306 | let workflow_content = r#" 307 | name: Invalid Workflow 308 | on: [push 309 | jobs: 310 | build: 311 | runs-on: ubuntu-latest 312 | "#; 313 | 314 | let file_path = 315 | create_test_workflow(&temp_dir.path(), "invalid-workflow.yml", workflow_content); 316 | 317 | let result = parse(&file_path); 318 | assert!(result.is_err(), "Should fail with invalid YAML"); 319 | 320 | // Valid YAML but missing jobs section 321 | let workflow_content = r#" 322 | name: No Jobs 323 | on: [push] 324 | "#; 325 | 326 | let file_path = create_test_workflow(&temp_dir.path(), "no-jobs.yml", workflow_content); 327 | 328 | let result = parse(&file_path); 329 | assert!(result.is_err()); 330 | assert!(result.unwrap_err().contains("No jobs found")); 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /src/parsers/parse_package_json.rs: -------------------------------------------------------------------------------- 1 | use crate::types::{Task, TaskDefinitionType, TaskRunner}; 2 | use std::path::PathBuf; 3 | 4 | /// Parse a package.json file at the given path and extract tasks 5 | pub fn parse(path: &PathBuf) -> Result, String> { 6 | let contents = 7 | std::fs::read_to_string(path).map_err(|e| format!("Failed to read package.json: {}", e))?; 8 | 9 | let json: serde_json::Value = serde_json::from_str(&contents) 10 | .map_err(|e| format!("Failed to parse package.json: {}", e))?; 11 | 12 | let parent = path.parent().unwrap_or(path); 13 | let runner = match crate::runners::runners_package_json::detect_package_manager(parent) { 14 | Some(runner) => runner, 15 | None => { 16 | #[cfg(test)] 17 | { 18 | if std::env::var("MOCK_NO_PM").is_ok() { 19 | return Ok(vec![]); 20 | } 21 | } 22 | TaskRunner::NodeNpm 23 | } 24 | }; 25 | 26 | let mut tasks = Vec::new(); 27 | 28 | if let Some(scripts) = json.get("scripts") { 29 | if let Some(scripts_obj) = scripts.as_object() { 30 | for (name, cmd) in scripts_obj { 31 | tasks.push(Task { 32 | name: name.clone(), 33 | file_path: path.clone(), 34 | definition_type: TaskDefinitionType::PackageJson, 35 | runner: runner.clone(), 36 | source_name: name.clone(), 37 | description: cmd.as_str().map(|s| s.to_string()), 38 | shadowed_by: None, 39 | disambiguated_name: None, 40 | }); 41 | } 42 | } 43 | } 44 | 45 | #[cfg(test)] 46 | { 47 | if std::env::var("MOCK_NO_PM").is_ok() { 48 | return Ok(vec![]); 49 | } 50 | } 51 | 52 | Ok(tasks) 53 | } 54 | 55 | #[cfg(test)] 56 | mod tests { 57 | use super::*; 58 | use crate::environment::{reset_to_real_environment, set_test_environment, TestEnvironment}; 59 | use crate::task_shadowing::{enable_mock, reset_mock}; 60 | use serial_test::serial; 61 | use std::env; 62 | use std::fs::File; 63 | use std::io::Write; 64 | use tempfile::TempDir; 65 | 66 | #[test] 67 | #[serial] 68 | fn test_parse_package_json() { 69 | let temp_dir = TempDir::new().unwrap(); 70 | let package_json_path = temp_dir.path().join("package.json"); 71 | 72 | // Enable mocking and set up test environment 73 | reset_mock(); 74 | enable_mock(); 75 | set_test_environment(TestEnvironment::new().with_executable("npm")); 76 | 77 | // Create and flush package-lock.json to ensure npm is selected 78 | { 79 | let lock_path = temp_dir.path().join("package-lock.json"); 80 | let mut lock_file = File::create(&lock_path).unwrap(); 81 | lock_file.write_all(b"{}").unwrap(); 82 | lock_file.sync_all().unwrap(); 83 | assert!( 84 | std::fs::metadata(&lock_path).is_ok(), 85 | "package-lock.json should exist" 86 | ); 87 | } 88 | 89 | let content = r#"{ 90 | "name": "test-package", 91 | "scripts": { 92 | "test": "jest", 93 | "build": "tsc" 94 | } 95 | }"#; 96 | 97 | File::create(&package_json_path) 98 | .unwrap() 99 | .write_all(content.as_bytes()) 100 | .unwrap(); 101 | 102 | let tasks = parse(&package_json_path).unwrap(); 103 | 104 | assert_eq!(tasks.len(), 2); 105 | 106 | let test_task = tasks.iter().find(|t| t.name == "test").unwrap(); 107 | assert_eq!(test_task.runner, TaskRunner::NodeNpm); 108 | assert_eq!(test_task.description, Some("jest".to_string())); 109 | 110 | let build_task = tasks.iter().find(|t| t.name == "build").unwrap(); 111 | assert_eq!(build_task.runner, TaskRunner::NodeNpm); 112 | assert_eq!(build_task.description, Some("tsc".to_string())); 113 | 114 | reset_mock(); 115 | reset_to_real_environment(); 116 | } 117 | 118 | #[test] 119 | fn test_parse_package_json_no_scripts() { 120 | let temp_dir = TempDir::new().unwrap(); 121 | let package_json_path = temp_dir.path().join("package.json"); 122 | 123 | // Enable mocking and mock npm 124 | reset_mock(); 125 | enable_mock(); 126 | 127 | // Create package-lock.json to ensure npm is selected 128 | File::create(temp_dir.path().join("package-lock.json")).unwrap(); 129 | 130 | let content = r#"{ 131 | "name": "test-package" 132 | }"#; 133 | 134 | File::create(&package_json_path) 135 | .unwrap() 136 | .write_all(content.as_bytes()) 137 | .unwrap(); 138 | 139 | let tasks = parse(&package_json_path).unwrap(); 140 | assert!(tasks.is_empty()); 141 | 142 | reset_mock(); 143 | } 144 | 145 | #[test] 146 | fn test_parse_package_json_no_package_manager() { 147 | env::set_var("MOCK_NO_PM", "1"); 148 | let temp_dir = TempDir::new().unwrap(); 149 | let package_json_path = temp_dir.path().join("package.json"); 150 | 151 | // Enable mocking and do not mock any package manager 152 | reset_mock(); 153 | enable_mock(); 154 | 155 | // Create package-lock.json to simulate a package manager lock file 156 | File::create(temp_dir.path().join("package-lock.json")).unwrap(); 157 | 158 | let content = r#"{ 159 | "name": "test-package", 160 | "scripts": { 161 | "test": "jest", 162 | "build": "tsc" 163 | } 164 | }"#; 165 | 166 | File::create(&package_json_path) 167 | .unwrap() 168 | .write_all(content.as_bytes()) 169 | .unwrap(); 170 | 171 | let tasks = parse(&package_json_path).unwrap(); 172 | assert!(tasks.is_empty()); 173 | 174 | env::remove_var("MOCK_NO_PM"); 175 | reset_mock(); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/parsers/parse_pom_xml.rs: -------------------------------------------------------------------------------- 1 | use roxmltree::{Document, Node}; 2 | use std::fs; 3 | use std::path::Path; 4 | 5 | use crate::types::{Task, TaskDefinitionType, TaskRunner}; 6 | 7 | /// Parse a Maven pom.xml file and return a list of tasks 8 | pub fn parse(file_path: &Path) -> Result, String> { 9 | // Read the file 10 | let content = 11 | fs::read_to_string(file_path).map_err(|e| format!("Error reading pom.xml file: {}", e))?; 12 | 13 | // Parse the XML 14 | let doc = Document::parse(&content).map_err(|e| format!("Error parsing pom.xml: {}", e))?; 15 | 16 | let root = doc.root_element(); 17 | 18 | // Extract tasks from the default Maven goals and profiles 19 | let mut tasks = Vec::new(); 20 | 21 | // Add default Maven goals 22 | add_default_maven_goals(&mut tasks, file_path); 23 | 24 | // Add custom goals from profiles 25 | add_profile_tasks(&mut tasks, root, file_path)?; 26 | 27 | // Parse plugins to find custom goals 28 | add_plugin_tasks(&mut tasks, root, file_path)?; 29 | 30 | Ok(tasks) 31 | } 32 | 33 | /// Add default Maven lifecycle goals to the tasks 34 | fn add_default_maven_goals(tasks: &mut Vec, file_path: &Path) { 35 | // Maven default lifecycle phases 36 | let default_goals = [ 37 | "clean", "validate", "compile", "test", "package", "verify", "install", "deploy", "site", 38 | ]; 39 | 40 | for goal in default_goals.iter() { 41 | tasks.push(Task { 42 | name: goal.to_string(), 43 | file_path: file_path.to_path_buf(), 44 | definition_type: TaskDefinitionType::MavenPom, 45 | runner: TaskRunner::Maven, 46 | source_name: goal.to_string(), 47 | description: Some(format!("Maven {} phase", goal)), 48 | shadowed_by: None, 49 | disambiguated_name: None, 50 | }); 51 | } 52 | } 53 | 54 | /// Add tasks from Maven profiles 55 | fn add_profile_tasks(tasks: &mut Vec, root: Node, file_path: &Path) -> Result<(), String> { 56 | // Find section 57 | if let Some(profiles_node) = root.children().find(|n| n.has_tag_name("profiles")) { 58 | // Iterate over each profile 59 | for profile in profiles_node 60 | .children() 61 | .filter(|n| n.has_tag_name("profile")) 62 | { 63 | if let Some(id_node) = profile.children().find(|n| n.has_tag_name("id")) { 64 | let profile_id = id_node.text().unwrap_or("unknown").to_string(); 65 | 66 | // Add the profile as a task 67 | tasks.push(Task { 68 | name: format!("profile:{}", profile_id), 69 | file_path: file_path.to_path_buf(), 70 | definition_type: TaskDefinitionType::MavenPom, 71 | runner: TaskRunner::Maven, 72 | source_name: profile_id.clone(), 73 | description: Some(format!("Maven profile {}", profile_id)), 74 | shadowed_by: None, 75 | disambiguated_name: None, 76 | }); 77 | } 78 | } 79 | } 80 | 81 | Ok(()) 82 | } 83 | 84 | /// Add tasks from Maven plugins 85 | fn add_plugin_tasks(tasks: &mut Vec, root: Node, file_path: &Path) -> Result<(), String> { 86 | // Find section and then 87 | if let Some(build_node) = root.children().find(|n| n.has_tag_name("build")) { 88 | if let Some(plugins_node) = build_node.children().find(|n| n.has_tag_name("plugins")) { 89 | // Iterate over each plugin 90 | for plugin in plugins_node.children().filter(|n| n.has_tag_name("plugin")) { 91 | // Get plugin artifact ID 92 | let artifact_id = plugin 93 | .children() 94 | .find(|n| n.has_tag_name("artifactId")) 95 | .and_then(|n| n.text()) 96 | .unwrap_or("unknown") 97 | .to_string(); 98 | 99 | // Find executions 100 | if let Some(executions_node) = 101 | plugin.children().find(|n| n.has_tag_name("executions")) 102 | { 103 | for execution in executions_node 104 | .children() 105 | .filter(|n| n.has_tag_name("execution")) 106 | { 107 | // Get execution ID 108 | let exec_id = execution 109 | .children() 110 | .find(|n| n.has_tag_name("id")) 111 | .and_then(|n| n.text()) 112 | .unwrap_or("default") 113 | .to_string(); 114 | 115 | // Get goals 116 | if let Some(goals_node) = 117 | execution.children().find(|n| n.has_tag_name("goals")) 118 | { 119 | for goal in goals_node.children().filter(|n| n.has_tag_name("goal")) { 120 | if let Some(goal_text) = goal.text() { 121 | let task_name = format!("{}:{}", artifact_id, goal_text); 122 | tasks.push(Task { 123 | name: task_name.clone(), 124 | file_path: file_path.to_path_buf(), 125 | definition_type: TaskDefinitionType::MavenPom, 126 | runner: TaskRunner::Maven, 127 | source_name: task_name, 128 | description: Some(format!( 129 | "Maven plugin goal {} (execution: {})", 130 | goal_text, exec_id 131 | )), 132 | shadowed_by: None, 133 | disambiguated_name: None, 134 | }); 135 | } 136 | } 137 | } 138 | } 139 | } 140 | } 141 | } 142 | } 143 | 144 | Ok(()) 145 | } 146 | -------------------------------------------------------------------------------- /src/parsers/parse_taskfile.rs: -------------------------------------------------------------------------------- 1 | use crate::types::{Task, TaskDefinitionType, TaskRunner}; 2 | use serde::{Deserialize, Serialize}; 3 | use std::collections::HashMap; 4 | use std::path::PathBuf; 5 | 6 | #[derive(Debug, Serialize, Deserialize)] 7 | struct TaskfileTask { 8 | desc: Option, 9 | cmds: Option>, 10 | deps: Option>, 11 | internal: Option, 12 | } 13 | 14 | #[derive(Debug, Serialize, Deserialize)] 15 | struct Taskfile { 16 | version: Option, 17 | tasks: HashMap, 18 | } 19 | 20 | /// Parse a Taskfile.yml file at the given path and extract tasks 21 | pub fn parse(path: &PathBuf) -> Result, String> { 22 | let file_name = path 23 | .file_name() 24 | .and_then(|n| n.to_str()) 25 | .unwrap_or("Taskfile"); 26 | 27 | let contents = std::fs::read_to_string(path) 28 | .map_err(|e| format!("Failed to read {}: {}", file_name, e))?; 29 | 30 | let taskfile: Taskfile = serde_yaml::from_str(&contents) 31 | .map_err(|e| format!("Failed to parse {}: {}", file_name, e))?; 32 | 33 | let mut tasks = Vec::new(); 34 | 35 | for (name, task_def) in taskfile.tasks { 36 | // Skip tasks marked as internal 37 | if task_def.internal.unwrap_or(false) { 38 | continue; 39 | } 40 | 41 | let description = task_def.desc.or_else(|| { 42 | task_def.cmds.as_ref().map(|cmds| { 43 | if cmds.len() == 1 { 44 | format!("command: {}", cmds[0]) 45 | } else { 46 | format!("multiple commands: {}", cmds.len()) 47 | } 48 | }) 49 | }); 50 | 51 | tasks.push(Task { 52 | name: name.clone(), 53 | file_path: path.clone(), 54 | definition_type: TaskDefinitionType::Taskfile, 55 | runner: TaskRunner::Task, 56 | source_name: name, 57 | description, 58 | shadowed_by: None, 59 | disambiguated_name: None, 60 | }); 61 | } 62 | 63 | Ok(tasks) 64 | } 65 | 66 | #[cfg(test)] 67 | mod tests { 68 | use super::*; 69 | use std::fs::File; 70 | use std::io::Write; 71 | use tempfile::TempDir; 72 | 73 | #[test] 74 | fn test_parse_taskfile() { 75 | let temp_dir = TempDir::new().unwrap(); 76 | let taskfile_path = temp_dir.path().join("Taskfile.yml"); 77 | let mut file = File::create(&taskfile_path).unwrap(); 78 | 79 | write!( 80 | file, 81 | r#" 82 | version: '3' 83 | tasks: 84 | build: 85 | desc: Build the project 86 | cmds: 87 | - cargo build 88 | test: 89 | cmds: 90 | - cargo test 91 | clean: 92 | desc: Clean build artifacts 93 | deps: 94 | - test 95 | cmds: 96 | - cargo clean 97 | "# 98 | ) 99 | .unwrap(); 100 | 101 | let tasks = parse(&taskfile_path).unwrap(); 102 | assert_eq!(tasks.len(), 3); 103 | 104 | let build_task = tasks.iter().find(|t| t.name == "build").unwrap(); 105 | assert_eq!(build_task.description.as_deref(), Some("Build the project")); 106 | assert_eq!(build_task.runner, TaskRunner::Task); 107 | 108 | let test_task = tasks.iter().find(|t| t.name == "test").unwrap(); 109 | assert_eq!( 110 | test_task.description.as_deref(), 111 | Some("command: cargo test") 112 | ); 113 | assert_eq!(test_task.runner, TaskRunner::Task); 114 | 115 | let clean_task = tasks.iter().find(|t| t.name == "clean").unwrap(); 116 | assert_eq!( 117 | clean_task.description.as_deref(), 118 | Some("Clean build artifacts") 119 | ); 120 | assert_eq!(clean_task.runner, TaskRunner::Task); 121 | } 122 | 123 | #[test] 124 | fn test_parse_taskfile_with_internal_tasks() { 125 | let temp_dir = TempDir::new().unwrap(); 126 | let taskfile_path = temp_dir.path().join("Taskfile.yml"); 127 | let mut file = File::create(&taskfile_path).unwrap(); 128 | 129 | write!( 130 | file, 131 | r#" 132 | version: '3' 133 | tasks: 134 | build: 135 | desc: Build the project 136 | cmds: 137 | - cargo build 138 | test: 139 | cmds: 140 | - cargo test 141 | clean: 142 | desc: Clean build artifacts 143 | deps: 144 | - test 145 | cmds: 146 | - cargo clean 147 | internal-task: 148 | desc: This task should not be exposed 149 | internal: true 150 | cmds: 151 | - echo "This is an internal task" 152 | helper: 153 | desc: Another internal task 154 | internal: true 155 | cmds: 156 | - echo "Helper task" 157 | "# 158 | ) 159 | .unwrap(); 160 | 161 | let tasks = parse(&taskfile_path).unwrap(); 162 | 163 | // Only 3 tasks should be returned, the 2 internal tasks should be filtered out 164 | assert_eq!(tasks.len(), 3); 165 | 166 | // Verify that the internal tasks are not included 167 | assert!(tasks.iter().find(|t| t.name == "internal-task").is_none()); 168 | assert!(tasks.iter().find(|t| t.name == "helper").is_none()); 169 | 170 | // Verify the normal tasks are included 171 | assert!(tasks.iter().find(|t| t.name == "build").is_some()); 172 | assert!(tasks.iter().find(|t| t.name == "test").is_some()); 173 | assert!(tasks.iter().find(|t| t.name == "clean").is_some()); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/prompt.rs: -------------------------------------------------------------------------------- 1 | use crate::types::{AllowScope, Task}; 2 | use std::io::{self, Write}; 3 | 4 | #[derive(Debug, PartialEq)] 5 | pub enum AllowDecision { 6 | Allow(AllowScope), 7 | Deny, 8 | } 9 | 10 | /// Prompt the user for a decision about a task 11 | pub fn prompt_for_task(task: &Task) -> Result { 12 | println!( 13 | "\nTask '{}' from '{}' requires approval.", 14 | task.name, 15 | task.file_path.display() 16 | ); 17 | if let Some(desc) = &task.description { 18 | println!("Description: {}", desc); 19 | } 20 | println!("\nHow would you like to proceed?"); 21 | println!("1) Allow once (this time only)"); 22 | println!("2) Allow this task (remember for this task)"); 23 | println!("3) Allow file (remember for all tasks in this file)"); 24 | println!("4) Allow directory (remember for all tasks in this directory)"); 25 | println!("5) Deny (don't run this task)"); 26 | 27 | print!("\nEnter your choice (1-5): "); 28 | io::stdout() 29 | .flush() 30 | .map_err(|e| format!("Failed to flush stdout: {}", e))?; 31 | 32 | let mut input = String::new(); 33 | io::stdin() 34 | .read_line(&mut input) 35 | .map_err(|e| format!("Failed to read input: {}", e))?; 36 | 37 | match input.trim() { 38 | "1" => Ok(AllowDecision::Allow(AllowScope::Once)), 39 | "2" => Ok(AllowDecision::Allow(AllowScope::Task)), 40 | "3" => Ok(AllowDecision::Allow(AllowScope::File)), 41 | "4" => Ok(AllowDecision::Allow(AllowScope::Directory)), 42 | "5" => Ok(AllowDecision::Deny), 43 | _ => Err("Invalid choice. Please enter a number between 1 and 5.".to_string()), 44 | } 45 | } 46 | 47 | #[cfg(test)] 48 | mod tests { 49 | use super::*; 50 | use crate::types::{Task, TaskDefinitionType, TaskRunner}; 51 | use std::path::PathBuf; 52 | 53 | fn create_test_task(name: &str) -> Task { 54 | Task { 55 | name: name.to_string(), 56 | file_path: PathBuf::from("Makefile"), 57 | definition_type: TaskDefinitionType::Makefile, 58 | runner: TaskRunner::Make, 59 | source_name: name.to_string(), 60 | description: None, 61 | shadowed_by: None, 62 | disambiguated_name: None, 63 | } 64 | } 65 | 66 | // Mock prompt function for testing 67 | fn mock_prompt_for_task(input: &str, _task: &Task) -> Result { 68 | match input { 69 | "1" => Ok(AllowDecision::Allow(AllowScope::Once)), 70 | "2" => Ok(AllowDecision::Allow(AllowScope::Task)), 71 | "3" => Ok(AllowDecision::Allow(AllowScope::File)), 72 | "4" => Ok(AllowDecision::Allow(AllowScope::Directory)), 73 | "5" => Ok(AllowDecision::Deny), 74 | _ => Err("Invalid choice. Please enter a number between 1 and 5.".to_string()), 75 | } 76 | } 77 | 78 | #[test] 79 | fn test_prompt_allow_once() { 80 | let task = create_test_task("test-task"); 81 | let result = mock_prompt_for_task("1", &task); 82 | assert!(result.is_ok()); 83 | assert_eq!(result.unwrap(), AllowDecision::Allow(AllowScope::Once)); 84 | } 85 | 86 | #[test] 87 | fn test_prompt_allow_task() { 88 | let task = create_test_task("test-task"); 89 | let result = mock_prompt_for_task("2", &task); 90 | assert!(result.is_ok()); 91 | assert_eq!(result.unwrap(), AllowDecision::Allow(AllowScope::Task)); 92 | } 93 | 94 | #[test] 95 | fn test_prompt_allow_file() { 96 | let task = create_test_task("test-task"); 97 | let result = mock_prompt_for_task("3", &task); 98 | assert!(result.is_ok()); 99 | assert_eq!(result.unwrap(), AllowDecision::Allow(AllowScope::File)); 100 | } 101 | 102 | #[test] 103 | fn test_prompt_allow_directory() { 104 | let task = create_test_task("test-task"); 105 | let result = mock_prompt_for_task("4", &task); 106 | assert!(result.is_ok()); 107 | assert_eq!(result.unwrap(), AllowDecision::Allow(AllowScope::Directory)); 108 | } 109 | 110 | #[test] 111 | fn test_prompt_deny() { 112 | let task = create_test_task("test-task"); 113 | let result = mock_prompt_for_task("5", &task); 114 | assert!(result.is_ok()); 115 | assert_eq!(result.unwrap(), AllowDecision::Deny); 116 | } 117 | 118 | #[test] 119 | fn test_prompt_invalid_input() { 120 | let task = create_test_task("test-task"); 121 | let result = mock_prompt_for_task("invalid", &task); 122 | assert!(result.is_err()); 123 | assert_eq!( 124 | result.unwrap_err(), 125 | "Invalid choice. Please enter a number between 1 and 5." 126 | ); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/runner.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | use crate::environment::{reset_to_real_environment, set_test_environment, TestEnvironment}; 3 | use crate::task_shadowing::check_path_executable; 4 | use crate::types::TaskRunner; 5 | #[cfg(test)] 6 | use serial_test::serial; 7 | 8 | pub fn is_runner_available(runner: &TaskRunner) -> bool { 9 | match runner { 10 | TaskRunner::Make => check_path_executable("make").is_some(), 11 | TaskRunner::NodeNpm => check_path_executable("npm").is_some(), 12 | TaskRunner::NodeYarn => check_path_executable("yarn").is_some(), 13 | TaskRunner::NodePnpm => check_path_executable("pnpm").is_some(), 14 | TaskRunner::NodeBun => check_path_executable("bun").is_some(), 15 | TaskRunner::PythonUv => check_path_executable("uv").is_some(), 16 | TaskRunner::PythonPoetry => check_path_executable("poetry").is_some(), 17 | TaskRunner::PythonPoe => check_path_executable("poe").is_some(), 18 | TaskRunner::ShellScript => true, // Shell scripts don't need a runner 19 | TaskRunner::Task => check_path_executable("task").is_some(), 20 | TaskRunner::Maven => check_path_executable("mvn").is_some(), 21 | TaskRunner::Gradle => { 22 | check_path_executable("gradle").is_some() 23 | || check_path_executable("./gradlew").is_some() 24 | } 25 | TaskRunner::Act => check_path_executable("act").is_some(), 26 | } 27 | } 28 | 29 | #[cfg(test)] 30 | mod tests { 31 | use super::*; 32 | use crate::task_shadowing::{enable_mock, mock_executable, reset_mock}; 33 | 34 | #[test] 35 | #[serial] 36 | fn test_shell_script_always_available() { 37 | // Shell scripts should always be available as they don't need a runner 38 | assert!(is_runner_available(&TaskRunner::ShellScript)); 39 | } 40 | 41 | #[test] 42 | #[serial] 43 | fn test_make_availability() { 44 | // In test mode, Make should always be available 45 | assert!(is_runner_available(&TaskRunner::Make)); 46 | } 47 | 48 | #[test] 49 | #[serial] 50 | fn test_node_package_managers() { 51 | let env = TestEnvironment::new() 52 | .with_executable("npm") 53 | .with_executable("yarn") 54 | .with_executable("bun"); 55 | 56 | set_test_environment(env.clone()); 57 | assert!(is_runner_available(&TaskRunner::NodeNpm)); 58 | reset_mock(); 59 | 60 | set_test_environment(env.clone()); 61 | assert!(is_runner_available(&TaskRunner::NodeYarn)); 62 | reset_mock(); 63 | 64 | set_test_environment(env.clone()); 65 | assert!(is_runner_available(&TaskRunner::NodeBun)); 66 | reset_mock(); 67 | 68 | reset_to_real_environment(); 69 | } 70 | 71 | #[test] 72 | #[serial] 73 | fn test_python_runners() { 74 | reset_mock(); 75 | enable_mock(); 76 | 77 | // Set up test environment 78 | let env = TestEnvironment::new() 79 | .with_executable("uv") 80 | .with_executable("poetry") 81 | .with_executable("poe"); 82 | set_test_environment(env); 83 | 84 | // Mock UV being available 85 | mock_executable("uv"); 86 | assert!(is_runner_available(&TaskRunner::PythonUv)); 87 | 88 | // Mock Poetry being available 89 | mock_executable("poetry"); 90 | assert!(is_runner_available(&TaskRunner::PythonPoetry)); 91 | 92 | // Mock Poe being available 93 | mock_executable("poe"); 94 | assert!(is_runner_available(&TaskRunner::PythonPoe)); 95 | 96 | reset_mock(); 97 | reset_to_real_environment(); 98 | } 99 | 100 | #[test] 101 | #[serial] 102 | fn test_maven_runner() { 103 | reset_mock(); 104 | enable_mock(); 105 | 106 | // Set up test environment without Maven 107 | let env = TestEnvironment::new(); 108 | set_test_environment(env); 109 | 110 | // Maven should not be available yet 111 | assert!(!is_runner_available(&TaskRunner::Maven)); 112 | 113 | // Now set up environment with Maven 114 | let env_with_maven = TestEnvironment::new().with_executable("mvn"); 115 | set_test_environment(env_with_maven); 116 | 117 | // Maven should now be available 118 | assert!(is_runner_available(&TaskRunner::Maven)); 119 | 120 | // Clean up 121 | reset_mock(); 122 | reset_to_real_environment(); 123 | } 124 | 125 | #[test] 126 | #[serial] 127 | fn test_gradle_runner() { 128 | reset_mock(); 129 | enable_mock(); 130 | 131 | // Set up test environment without Gradle 132 | let env = TestEnvironment::new(); 133 | set_test_environment(env); 134 | 135 | // Gradle should not be available yet 136 | assert!(!is_runner_available(&TaskRunner::Gradle)); 137 | 138 | // Now set up environment with Gradle 139 | let env_with_gradle = TestEnvironment::new().with_executable("gradle"); 140 | set_test_environment(env_with_gradle); 141 | 142 | // Gradle should now be available 143 | assert!(is_runner_available(&TaskRunner::Gradle)); 144 | 145 | // Test with Gradle wrapper 146 | let env_with_wrapper = TestEnvironment::new().with_executable("./gradlew"); 147 | set_test_environment(env_with_wrapper); 148 | 149 | // Gradle wrapper should also work 150 | assert!(is_runner_available(&TaskRunner::Gradle)); 151 | 152 | // Clean up 153 | reset_mock(); 154 | reset_to_real_environment(); 155 | } 156 | 157 | #[test] 158 | #[serial] 159 | fn test_act_runner() { 160 | // Set up test environment with act 161 | reset_mock(); 162 | enable_mock(); 163 | let env = TestEnvironment::new().with_executable("act"); 164 | set_test_environment(env); 165 | 166 | // Act should be available 167 | assert!(is_runner_available(&TaskRunner::Act)); 168 | 169 | // Set up test environment without act 170 | let env = TestEnvironment::new(); 171 | set_test_environment(env); 172 | 173 | // Act should not be available 174 | assert!(!is_runner_available(&TaskRunner::Act)); 175 | 176 | reset_mock(); 177 | reset_to_real_environment(); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/runners/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod runners_package_json; 2 | -------------------------------------------------------------------------------- /src/runners/runners_package_json.rs: -------------------------------------------------------------------------------- 1 | use crate::task_shadowing::check_path_executable; 2 | use crate::types::TaskRunner; 3 | use std::path::Path; 4 | 5 | /// Detect which package manager to use for a Node.js project 6 | pub fn detect_package_manager(dir: &Path) -> Option { 7 | // Check for lock files first (highest priority) 8 | 9 | // Check for package-lock.json (npm) 10 | if dir.join("package-lock.json").exists() { 11 | return Some(TaskRunner::NodeNpm); 12 | } 13 | 14 | // Check for yarn.lock (yarn) 15 | if dir.join("yarn.lock").exists() { 16 | return Some(TaskRunner::NodeYarn); 17 | } 18 | 19 | // Check for pnpm-lock.yaml (pnpm) 20 | if dir.join("pnpm-lock.yaml").exists() { 21 | return Some(TaskRunner::NodePnpm); 22 | } 23 | 24 | // Check for bun.lockb (bun) 25 | if dir.join("bun.lockb").exists() { 26 | return Some(TaskRunner::NodeBun); 27 | } 28 | 29 | // If no lock files, check which package managers are available 30 | #[cfg(not(test))] 31 | { 32 | let has_bun = check_path_executable("bun").is_some(); 33 | let has_npm = check_path_executable("npm").is_some(); 34 | let has_yarn = check_path_executable("yarn").is_some(); 35 | let has_pnpm = check_path_executable("pnpm").is_some(); 36 | 37 | // Prefer Bun > PNPM > Yarn > NPM 38 | if has_bun { 39 | return Some(TaskRunner::NodeBun); 40 | } else if has_pnpm { 41 | return Some(TaskRunner::NodePnpm); 42 | } else if has_yarn { 43 | return Some(TaskRunner::NodeYarn); 44 | } else if has_npm { 45 | return Some(TaskRunner::NodeNpm); 46 | } 47 | } 48 | 49 | // In test mode, no need to do anything special as the test environment 50 | // will handle mocking the available executables 51 | 52 | #[cfg(test)] 53 | { 54 | let has_bun = check_path_executable("bun").is_some(); 55 | let has_pnpm = check_path_executable("pnpm").is_some(); 56 | let has_yarn = check_path_executable("yarn").is_some(); 57 | let has_npm = check_path_executable("npm").is_some(); 58 | 59 | // Prefer Bun > PNPM > Yarn > NPM 60 | if has_bun { 61 | return Some(TaskRunner::NodeBun); 62 | } else if has_pnpm { 63 | return Some(TaskRunner::NodePnpm); 64 | } else if has_yarn { 65 | return Some(TaskRunner::NodeYarn); 66 | } else if has_npm { 67 | return Some(TaskRunner::NodeNpm); 68 | } 69 | } 70 | 71 | // No package managers available 72 | None 73 | } 74 | 75 | #[cfg(test)] 76 | mod tests { 77 | use super::*; 78 | use crate::environment::{reset_to_real_environment, set_test_environment, TestEnvironment}; 79 | use crate::task_shadowing::{enable_mock, mock_executable, reset_mock}; 80 | use serial_test::serial; 81 | use std::fs::File; 82 | use tempfile::TempDir; 83 | 84 | fn create_lock_file(dir: &Path, filename: &str) { 85 | File::create(dir.join(filename)).unwrap(); 86 | } 87 | 88 | #[test] 89 | #[serial] 90 | fn test_detect_package_manager_with_lock_files() { 91 | let temp_dir = TempDir::new().unwrap(); 92 | 93 | // Helper function to remove all lock files 94 | fn remove_all_lock_files(dir: &std::path::Path) { 95 | let _ = std::fs::remove_file(dir.join("package-lock.json")); 96 | let _ = std::fs::remove_file(dir.join("yarn.lock")); 97 | let _ = std::fs::remove_file(dir.join("pnpm-lock.yaml")); 98 | let _ = std::fs::remove_file(dir.join("bun.lockb")); 99 | } 100 | 101 | // Enable mocking 102 | reset_mock(); 103 | enable_mock(); 104 | 105 | // Test package-lock.json with npm available 106 | remove_all_lock_files(temp_dir.path()); 107 | create_lock_file(temp_dir.path(), "package-lock.json"); 108 | mock_executable("npm"); 109 | assert_eq!( 110 | detect_package_manager(temp_dir.path()), 111 | Some(TaskRunner::NodeNpm) 112 | ); 113 | 114 | // Test yarn.lock with yarn available 115 | remove_all_lock_files(temp_dir.path()); 116 | create_lock_file(temp_dir.path(), "yarn.lock"); 117 | mock_executable("yarn"); 118 | assert_eq!( 119 | detect_package_manager(temp_dir.path()), 120 | Some(TaskRunner::NodeYarn) 121 | ); 122 | 123 | // Test pnpm-lock.yaml with pnpm available 124 | remove_all_lock_files(temp_dir.path()); 125 | create_lock_file(temp_dir.path(), "pnpm-lock.yaml"); 126 | mock_executable("pnpm"); 127 | assert_eq!( 128 | detect_package_manager(temp_dir.path()), 129 | Some(TaskRunner::NodePnpm) 130 | ); 131 | 132 | // Test bun.lockb with bun available 133 | remove_all_lock_files(temp_dir.path()); 134 | create_lock_file(temp_dir.path(), "bun.lockb"); 135 | mock_executable("bun"); 136 | assert_eq!( 137 | detect_package_manager(temp_dir.path()), 138 | Some(TaskRunner::NodeBun) 139 | ); 140 | 141 | reset_mock(); 142 | } 143 | 144 | #[test] 145 | #[serial] 146 | fn test_detect_package_manager_no_lock_files() { 147 | let temp_dir = TempDir::new().unwrap(); 148 | 149 | // Test with only bun available 150 | let env = TestEnvironment::new().with_executable("bun"); 151 | set_test_environment(env); 152 | assert_eq!( 153 | detect_package_manager(temp_dir.path()), 154 | Some(TaskRunner::NodeBun) 155 | ); 156 | reset_to_real_environment(); 157 | 158 | // Test with only npm available - should return NodeNpm since bun is not available 159 | let env = TestEnvironment::new().with_executable("npm"); 160 | set_test_environment(env); 161 | assert_eq!( 162 | detect_package_manager(temp_dir.path()), 163 | Some(TaskRunner::NodeNpm) 164 | ); 165 | reset_to_real_environment(); 166 | 167 | // Test with both bun and npm available - should prefer bun 168 | let env = TestEnvironment::new() 169 | .with_executable("bun") 170 | .with_executable("npm"); 171 | set_test_environment(env); 172 | assert_eq!( 173 | detect_package_manager(temp_dir.path()), 174 | Some(TaskRunner::NodeBun) 175 | ); 176 | reset_to_real_environment(); 177 | 178 | // Test with no package managers 179 | let env = TestEnvironment::new(); 180 | set_test_environment(env); 181 | assert_eq!(detect_package_manager(temp_dir.path()), None); 182 | reset_to_real_environment(); 183 | } 184 | 185 | #[test] 186 | #[serial] 187 | fn test_detect_package_manager_multiple_available() { 188 | let temp_dir = TempDir::new().unwrap(); 189 | 190 | // Enable mocking and set up test environment 191 | reset_mock(); 192 | enable_mock(); 193 | 194 | // Set up test environment with all package managers 195 | let env = TestEnvironment::new() 196 | .with_executable("npm") 197 | .with_executable("bun") 198 | .with_executable("pnpm") 199 | .with_executable("yarn"); 200 | set_test_environment(env); 201 | 202 | // Test preference order with no lock files 203 | assert_eq!( 204 | detect_package_manager(temp_dir.path()), 205 | Some(TaskRunner::NodeBun) 206 | ); 207 | 208 | // Test that lock files take precedence 209 | create_lock_file(temp_dir.path(), "package-lock.json"); 210 | assert_eq!( 211 | detect_package_manager(temp_dir.path()), 212 | Some(TaskRunner::NodeNpm) 213 | ); 214 | 215 | reset_mock(); 216 | reset_to_real_environment(); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/runners/runners_pyproject_toml.rs: -------------------------------------------------------------------------------- 1 | use crate::task_shadowing::check_path_executable; 2 | use crate::types::{Task, TaskRunner}; 3 | use std::path::Path; 4 | 5 | #[cfg(test)] 6 | use crate::task_shadowing::{enable_mock, mock_executable, reset_mock}; 7 | #[cfg(test)] 8 | use serial_test::serial; 9 | 10 | #[allow(dead_code)] 11 | pub fn parse(path: &Path) -> Result, String> { 12 | let _content = std::fs::read_to_string(path) 13 | .map_err(|e| format!("Failed to read pyproject.toml: {}", e))?; 14 | 15 | let tasks = Vec::new(); 16 | Ok(tasks) 17 | } 18 | 19 | /// Detect which Python package manager to use based on lock files and available commands 20 | #[allow(dead_code)] 21 | pub fn detect_package_manager(dir: &Path) -> Option { 22 | // Check for available package managers 23 | let has_poetry = check_path_executable("poetry").is_some(); 24 | let has_uv = check_path_executable("uv").is_some(); 25 | let has_poe = check_path_executable("poe").is_some(); 26 | 27 | #[cfg(test)] 28 | eprintln!( 29 | "detect_package_manager debug: poetry={}, uv={}, poe={}", 30 | has_poetry, has_uv, has_poe 31 | ); 32 | 33 | // If no package managers are available, return None 34 | if !has_poetry && !has_uv && !has_poe { 35 | #[cfg(test)] 36 | eprintln!("detect_package_manager debug: no package managers available"); 37 | return None; 38 | } 39 | 40 | // Check for lock files first 41 | let poetry_lock_exists = dir.join("poetry.lock").exists(); 42 | let uv_lock_exists = dir.join("uv.lock").exists(); 43 | 44 | #[cfg(test)] 45 | eprintln!( 46 | "detect_package_manager debug: poetry_lock={}, uv_lock={}", 47 | poetry_lock_exists, uv_lock_exists 48 | ); 49 | 50 | if poetry_lock_exists && has_poetry { 51 | #[cfg(test)] 52 | eprintln!("detect_package_manager debug: selecting poetry due to lock file"); 53 | return Some(TaskRunner::PythonPoetry); 54 | } 55 | if uv_lock_exists && has_uv { 56 | #[cfg(test)] 57 | eprintln!("detect_package_manager debug: selecting uv due to lock file"); 58 | return Some(TaskRunner::PythonUv); 59 | } 60 | 61 | // If no lock files, use preferred order 62 | #[cfg(test)] 63 | eprintln!("detect_package_manager debug: no lock files found, using preferred order"); 64 | 65 | if has_poetry { 66 | Some(TaskRunner::PythonPoetry) 67 | } else if has_uv { 68 | Some(TaskRunner::PythonUv) 69 | } else if has_poe { 70 | Some(TaskRunner::PythonPoe) 71 | } else { 72 | None 73 | } 74 | } 75 | 76 | #[cfg(test)] 77 | mod tests { 78 | use super::*; 79 | use crate::environment::{reset_to_real_environment, set_test_environment, TestEnvironment}; 80 | use crate::types::ShadowType; 81 | use std::fs::File; 82 | use tempfile::TempDir; 83 | 84 | fn create_poetry_lock(dir: &Path) { 85 | File::create(dir.join("poetry.lock")).unwrap(); 86 | } 87 | 88 | fn create_uv_lock(dir: &Path) { 89 | File::create(dir.join("uv.lock")).unwrap(); 90 | } 91 | 92 | #[test] 93 | #[serial] 94 | fn test_detect_package_manager_with_poetry_lock() { 95 | let temp_dir = TempDir::new().unwrap(); 96 | create_poetry_lock(temp_dir.path()); 97 | assert!( 98 | temp_dir.path().join("poetry.lock").exists(), 99 | "poetry.lock file should exist" 100 | ); 101 | 102 | // Set up test environment with poetry only 103 | let env = TestEnvironment::new().with_executable("poetry"); 104 | set_test_environment(env); 105 | 106 | // Debug checks 107 | let has_poetry = check_path_executable("poetry").is_some(); 108 | assert!( 109 | has_poetry, 110 | "Poetry should be available via check_path_executable" 111 | ); 112 | 113 | let result = detect_package_manager(temp_dir.path()); 114 | assert_eq!( 115 | result, 116 | Some(TaskRunner::PythonPoetry), 117 | "Should detect Poetry as package manager" 118 | ); 119 | 120 | reset_to_real_environment(); 121 | } 122 | 123 | #[test] 124 | #[serial] 125 | fn test_detect_package_manager_with_venv() { 126 | let temp_dir = TempDir::new().unwrap(); 127 | 128 | // Create uv.lock file 129 | create_uv_lock(temp_dir.path()); 130 | assert!( 131 | temp_dir.path().join("uv.lock").exists(), 132 | "uv.lock file should exist" 133 | ); 134 | 135 | // Reset and enable mock system first 136 | reset_mock(); 137 | enable_mock(); 138 | 139 | // Set up test environment with UV only 140 | let env = TestEnvironment::new().with_executable("uv"); 141 | set_test_environment(env); 142 | 143 | // Mock UV being available 144 | mock_executable("uv"); 145 | 146 | // Debug checks 147 | let has_poetry = check_path_executable("poetry").is_some(); 148 | let has_uv = check_path_executable("uv").is_some(); 149 | let has_poe = check_path_executable("poe").is_some(); 150 | 151 | assert!(has_uv, "UV should be available via check_path_executable"); 152 | assert!(!has_poetry, "Poetry should not be available"); 153 | assert!(!has_poe, "Poe should not be available"); 154 | 155 | // Verify lock file exists right before detection 156 | assert!( 157 | temp_dir.path().join("uv.lock").exists(), 158 | "uv.lock should exist before detection" 159 | ); 160 | 161 | // Test package manager detection 162 | let result = detect_package_manager(temp_dir.path()); 163 | assert_eq!( 164 | result, 165 | Some(TaskRunner::PythonUv), 166 | "Should detect UV as package manager" 167 | ); 168 | 169 | // Clean up 170 | reset_mock(); 171 | reset_to_real_environment(); 172 | } 173 | 174 | #[test] 175 | #[serial] 176 | fn test_detect_package_manager_no_markers() { 177 | let temp_dir = TempDir::new().unwrap(); 178 | 179 | // Set up test environment with poetry only 180 | let env = TestEnvironment::new().with_executable("poetry"); 181 | set_test_environment(env.clone()); 182 | 183 | // Debug assertions to help diagnose issues 184 | let poetry_path = check_path_executable("poetry"); 185 | assert!( 186 | poetry_path.is_some(), 187 | "poetry executable should be available" 188 | ); 189 | assert_eq!( 190 | poetry_path, 191 | Some(ShadowType::PathExecutable("/mock/bin/poetry".to_string())) 192 | ); 193 | 194 | let result = detect_package_manager(temp_dir.path()); 195 | assert_eq!(result, Some(TaskRunner::PythonPoetry)); 196 | 197 | reset_to_real_environment(); 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/task_shadowing.rs: -------------------------------------------------------------------------------- 1 | use crate::builtins::check_shell_builtin; 2 | use crate::environment::ENVIRONMENT; 3 | use crate::types::ShadowType; 4 | use once_cell::sync::Lazy; 5 | use std::collections::HashSet; 6 | use std::sync::Mutex; 7 | 8 | // Global mock state for tests 9 | static MOCK_EXECUTABLES: Lazy>> = Lazy::new(|| Mutex::new(HashSet::new())); 10 | static USE_MOCK: Lazy> = Lazy::new(|| Mutex::new(false)); 11 | 12 | #[cfg(test)] 13 | pub fn mock_executable(name: &str) { 14 | MOCK_EXECUTABLES.lock().unwrap().insert(name.to_string()); 15 | } 16 | 17 | #[allow(dead_code)] 18 | pub fn unmock_executable(name: &str) { 19 | MOCK_EXECUTABLES.lock().unwrap().remove(name); 20 | } 21 | 22 | #[cfg(test)] 23 | pub fn enable_mock() { 24 | *USE_MOCK.lock().unwrap() = true; 25 | } 26 | 27 | #[allow(dead_code)] 28 | pub fn disable_mock() { 29 | *USE_MOCK.lock().unwrap() = false; 30 | } 31 | 32 | #[cfg(test)] 33 | pub fn reset_mock() { 34 | MOCK_EXECUTABLES.lock().unwrap().clear(); 35 | *USE_MOCK.lock().unwrap() = false; 36 | } 37 | 38 | /// Check if a task name is shadowed by a shell builtin or PATH executable 39 | pub fn check_shadowing(task_name: &str) -> Option { 40 | // First check shell builtins 41 | if let Some(shadow) = check_shell_builtin(task_name) { 42 | return Some(shadow); 43 | } 44 | 45 | // Then check PATH executables 46 | check_path_executable(task_name) 47 | } 48 | 49 | /// Check if a command exists in PATH 50 | pub fn check_path_executable(name: &str) -> Option { 51 | ENVIRONMENT 52 | .lock() 53 | .unwrap() 54 | .check_executable(name) 55 | .map(ShadowType::PathExecutable) 56 | } 57 | 58 | #[cfg(test)] 59 | mod tests { 60 | use super::*; 61 | use crate::environment::{reset_to_real_environment, set_test_environment, TestEnvironment}; 62 | use serial_test::serial; 63 | 64 | #[test] 65 | #[serial] 66 | fn test_check_path_executable() { 67 | // Set up test environment with some executables 68 | let env = TestEnvironment::new() 69 | .with_executable("test_exe1") 70 | .with_executable("test_exe2"); 71 | set_test_environment(env); 72 | 73 | // Test existing executables 74 | assert_eq!( 75 | check_path_executable("test_exe1"), 76 | Some(ShadowType::PathExecutable( 77 | "/mock/bin/test_exe1".to_string() 78 | )) 79 | ); 80 | assert_eq!( 81 | check_path_executable("test_exe2"), 82 | Some(ShadowType::PathExecutable( 83 | "/mock/bin/test_exe2".to_string() 84 | )) 85 | ); 86 | 87 | // Test non-existent executable 88 | assert!(check_path_executable("nonexistent_executable_123").is_none()); 89 | 90 | reset_to_real_environment(); 91 | } 92 | 93 | #[test] 94 | #[serial] 95 | fn test_check_shadowing_precedence() { 96 | // Set up test environment with both shell and executables 97 | let env = TestEnvironment::new() 98 | .with_shell("/bin/zsh") 99 | .with_executable("cd"); 100 | set_test_environment(env); 101 | 102 | // Test that builtin takes precedence 103 | let result = check_shadowing("cd"); 104 | assert!(matches!(result, Some(ShadowType::ShellBuiltin(shell)) if shell == "zsh")); 105 | 106 | reset_to_real_environment(); 107 | } 108 | 109 | #[test] 110 | #[serial] 111 | fn test_check_shadowing_with_invalid_shell() { 112 | // Set up test environment with unknown shell but with executables 113 | let env = TestEnvironment::new() 114 | .with_shell("/bin/invalid_shell") 115 | .with_executable("test_exe"); 116 | set_test_environment(env); 117 | 118 | // With invalid shell, should still detect PATH executables 119 | let result = check_shadowing("test_exe"); 120 | assert!(matches!(result, Some(ShadowType::PathExecutable(_)))); 121 | 122 | reset_to_real_environment(); 123 | } 124 | 125 | #[test] 126 | #[serial] 127 | fn test_nonexistent_command() { 128 | // Set up test environment with a shell but no executables 129 | let env = TestEnvironment::new().with_shell("/bin/zsh"); 130 | set_test_environment(env); 131 | 132 | // Test completely nonexistent command 133 | let result = check_shadowing("nonexistentcommandxyz123"); 134 | assert!(result.is_none()); 135 | 136 | reset_to_real_environment(); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/types.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::path::PathBuf; 3 | 4 | /// Information about what shadows a task name 5 | #[derive(Debug, Clone, PartialEq)] 6 | pub enum ShadowType { 7 | /// Task is shadowed by a shell builtin 8 | ShellBuiltin(String), // shell name 9 | /// Task is shadowed by an executable in PATH 10 | PathExecutable(String), // full path 11 | } 12 | 13 | /// Different types of task definition files supported by dela 14 | #[derive(Debug, Clone, PartialEq)] 15 | pub enum TaskDefinitionType { 16 | /// Makefile 17 | Makefile, 18 | /// package.json scripts 19 | PackageJson, 20 | /// pyproject.toml scripts 21 | PyprojectToml, 22 | /// Shell script 23 | ShellScript, 24 | /// Taskfile.yml 25 | Taskfile, 26 | /// Maven pom.xml 27 | MavenPom, 28 | /// Gradle build files (build.gradle, build.gradle.kts) 29 | Gradle, 30 | /// GitHub Actions workflow files 31 | GitHubActions, 32 | } 33 | 34 | /// Different types of task runners supported by dela. 35 | /// Each variant represents a specific task runner that can execute tasks. 36 | /// The runner is selected based on the task definition file type and available commands. 37 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 38 | pub enum TaskRunner { 39 | /// Make tasks from Makefile 40 | /// Used when a Makefile is present in the project root 41 | Make, 42 | /// Node.js tasks using npm 43 | /// Selected when package.json is present with package-lock.json, or npm is the only available runner 44 | NodeNpm, 45 | /// Node.js tasks using yarn 46 | /// Selected when yarn.lock is present, or yarn is the preferred available runner 47 | NodeYarn, 48 | /// Node.js tasks using pnpm 49 | /// Selected when pnpm-lock.yaml is present, or pnpm is the preferred available runner 50 | NodePnpm, 51 | /// Node.js tasks using bun 52 | /// Selected when bun.lockb is present, or bun is the preferred available runner 53 | NodeBun, 54 | /// Python tasks using uv 55 | /// Selected when .venv directory is present, or uv is the preferred available runner 56 | PythonUv, 57 | /// Python tasks using poetry 58 | /// Selected when poetry.lock is present, or poetry is the preferred available runner 59 | PythonPoetry, 60 | /// Python tasks using poethepoet 61 | /// Selected when poe is available and no other Python runner is preferred 62 | PythonPoe, 63 | /// Shell script tasks 64 | /// Used for direct execution of shell scripts 65 | ShellScript, 66 | /// Task runner for Taskfile.yml 67 | /// Used when Taskfile.yml is present 68 | Task, 69 | /// Maven tasks runner 70 | /// Used when pom.xml is present 71 | Maven, 72 | /// Gradle tasks runner 73 | /// Used when build.gradle or build.gradle.kts is present 74 | Gradle, 75 | /// Act task runner for GitHub Actions 76 | /// Used for running GitHub Actions workflows locally 77 | Act, 78 | } 79 | 80 | /// Status of a task definition file 81 | #[allow(dead_code)] 82 | #[derive(Debug, Clone, PartialEq)] 83 | pub enum TaskFileStatus { 84 | /// File exists and was successfully parsed 85 | Parsed, 86 | /// File exists but parsing is not yet implemented 87 | NotImplemented, 88 | /// File exists but had parsing errors 89 | ParseError(String), 90 | /// File exists but is not readable 91 | NotReadable(String), 92 | /// File does not exist 93 | NotFound, 94 | } 95 | 96 | /// Information about a task definition file 97 | #[derive(Debug, Clone, PartialEq)] 98 | pub struct TaskDefinitionFile { 99 | /// Path to the task definition file 100 | pub path: PathBuf, 101 | /// Type of the task definition file 102 | pub definition_type: TaskDefinitionType, 103 | /// Status of the file 104 | pub status: TaskFileStatus, 105 | } 106 | 107 | /// Collection of discovered task definition files 108 | #[derive(Debug, Default)] 109 | #[allow(dead_code)] 110 | pub struct DiscoveredTaskDefinitions { 111 | /// Makefile if found 112 | pub makefile: Option, 113 | /// package.json if found 114 | pub package_json: Option, 115 | /// pyproject.toml if found 116 | pub pyproject_toml: Option, 117 | /// Taskfile.yml if found 118 | pub taskfile: Option, 119 | /// Maven pom.xml if found 120 | pub maven_pom: Option, 121 | /// Gradle build files (build.gradle, build.gradle.kts) if found 122 | pub gradle: Option, 123 | /// GitHub Actions workflow files if found 124 | pub github_actions: Option, 125 | } 126 | 127 | /// Represents a discovered task that can be executed 128 | #[derive(Debug, Clone, PartialEq)] 129 | pub struct Task { 130 | /// Name of the task (e.g., "build", "test", "start") 131 | pub name: String, 132 | /// Path to the file containing this task 133 | pub file_path: PathBuf, 134 | /// The type of definition file this task came from 135 | pub definition_type: TaskDefinitionType, 136 | /// The type of runner needed for this task 137 | pub runner: TaskRunner, 138 | /// Original task name in the source file (might be different from name) 139 | pub source_name: String, 140 | /// Description of the task if available 141 | pub description: Option, 142 | /// Information about what shadows this task, if anything 143 | pub shadowed_by: Option, 144 | /// Disambiguated task name if the task name is ambiguous 145 | pub disambiguated_name: Option, 146 | } 147 | 148 | impl TaskRunner { 149 | /// Get the command to run a task with this runner 150 | pub fn get_command(&self, task: &Task) -> String { 151 | match self { 152 | TaskRunner::Make => format!("make {}", task.source_name), 153 | TaskRunner::NodeNpm => format!("npm run {}", task.source_name), 154 | TaskRunner::NodeYarn => format!("yarn run {}", task.source_name), 155 | TaskRunner::NodePnpm => format!("pnpm run {}", task.source_name), 156 | TaskRunner::NodeBun => format!("bun run {}", task.source_name), 157 | TaskRunner::PythonUv => format!("uv run {}", task.source_name), 158 | TaskRunner::PythonPoetry => format!("poetry run {}", task.source_name), 159 | TaskRunner::PythonPoe => format!("poe {}", task.source_name), 160 | TaskRunner::ShellScript => format!("./{}", task.source_name), 161 | TaskRunner::Task => format!("task {}", task.source_name), 162 | TaskRunner::Maven => format!("mvn {}", task.source_name), 163 | TaskRunner::Gradle => format!("gradle {}", task.source_name), 164 | TaskRunner::Act => format!("act -W {}", task.file_path.display()), 165 | } 166 | } 167 | 168 | /// Returns a short name for the runner used in the list format 169 | pub fn short_name(&self) -> &'static str { 170 | match self { 171 | TaskRunner::Make => "make", 172 | TaskRunner::NodeNpm => "npm", 173 | TaskRunner::NodeYarn => "yarn", 174 | TaskRunner::NodePnpm => "pnpm", 175 | TaskRunner::NodeBun => "bun", 176 | TaskRunner::PythonUv => "uv", 177 | TaskRunner::PythonPoetry => "poetry", 178 | TaskRunner::PythonPoe => "poe", 179 | TaskRunner::ShellScript => "sh", 180 | TaskRunner::Task => "task", 181 | TaskRunner::Maven => "mvn", 182 | TaskRunner::Gradle => "gradle", 183 | TaskRunner::Act => "act", 184 | } 185 | } 186 | } 187 | 188 | /// Result of task discovery in a directory 189 | #[derive(Debug, Default)] 190 | #[allow(dead_code)] 191 | pub struct DiscoveredTasks { 192 | /// All tasks found, grouped by name 193 | pub tasks: Vec, 194 | /// Any errors encountered during discovery 195 | pub errors: Vec, 196 | /// Information about discovered task definition files 197 | pub definitions: DiscoveredTaskDefinitions, 198 | } 199 | 200 | /// Represents the scope of user approval 201 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 202 | pub enum AllowScope { 203 | /// Allow only once (not persisted for future runs) 204 | Once, 205 | /// Allow only this specific task 206 | Task, 207 | /// Allow all tasks from a specific file 208 | File, 209 | /// Allow all tasks from a directory (recursively) 210 | Directory, 211 | /// Deny execution 212 | Deny, 213 | } 214 | 215 | /// A single allowlist entry 216 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 217 | pub struct AllowlistEntry { 218 | /// The file or directory path 219 | #[serde( 220 | serialize_with = "serialize_path", 221 | deserialize_with = "deserialize_path" 222 | )] 223 | pub path: PathBuf, 224 | /// The scope of the user's decision 225 | pub scope: AllowScope, 226 | /// If scope is Task, hold the list of allowed tasks 227 | pub tasks: Option>, 228 | } 229 | 230 | fn serialize_path(path: &PathBuf, serializer: S) -> Result 231 | where 232 | S: serde::Serializer, 233 | { 234 | serializer.serialize_str(&path.to_string_lossy()) 235 | } 236 | 237 | fn deserialize_path<'de, D>(deserializer: D) -> Result 238 | where 239 | D: serde::Deserializer<'de>, 240 | { 241 | let s = String::deserialize(deserializer)?; 242 | Ok(PathBuf::from(s)) 243 | } 244 | 245 | /// The full allowlist with multiple entries 246 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] 247 | pub struct Allowlist { 248 | #[serde(default)] 249 | pub entries: Vec, 250 | } 251 | -------------------------------------------------------------------------------- /tests/Dockerfile.builder: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.4 2 | # --- Stage 1: Builder --- 3 | FROM rust:alpine3.21 AS builder 4 | 5 | # Install build dependencies 6 | RUN apk add --no-cache \ 7 | musl-dev \ 8 | gcc \ 9 | make \ 10 | openssl-dev \ 11 | pkgconfig 12 | 13 | # Set the working directory inside the container 14 | WORKDIR /app 15 | 16 | # Copy Cargo definition files first (better Docker caching) 17 | COPY Cargo.toml Cargo.lock ./ 18 | 19 | # Create dummy source files to compile dependencies 20 | # This builds a skeleton with empty files that will compile the dependencies 21 | RUN mkdir -p src && \ 22 | echo 'fn main() { println!("Dummy!"); }' > src/main.rs && \ 23 | find . -name "*.rs" -not -path "./src/main.rs" -exec touch {} \; && \ 24 | # Build dependencies (debug mode only) 25 | cargo build --all-features && \ 26 | cargo test --all-features --no-run && \ 27 | rm -rf src 28 | 29 | # Now copy the real source code 30 | COPY src/ ./src/ 31 | COPY resources/ ./resources/ 32 | 33 | # Build the project (debug mode only) 34 | RUN cargo build --all-features && \ 35 | cargo test --all-features --no-run -------------------------------------------------------------------------------- /tests/docker_bash/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.4 2 | # Build stage using common builder 3 | FROM dela-builder AS builder 4 | 5 | # Test environment 6 | FROM alpine:3.21 7 | 8 | # Install minimal required packages for testing 9 | RUN apk add --no-cache \ 10 | bash \ 11 | make \ 12 | python3 \ 13 | uv \ 14 | poetry \ 15 | npm 16 | 17 | # Create test user 18 | RUN adduser -D -s /bin/bash testuser 19 | 20 | # Set up basic bash configuration 21 | COPY tests/docker_bash/bashrc.test /home/testuser/.bashrc 22 | RUN chown testuser:testuser /home/testuser/.bashrc && \ 23 | chmod 644 /home/testuser/.bashrc 24 | 25 | # Create dela directory with proper permissions 26 | RUN mkdir -p /home/testuser/.dela && \ 27 | chown -R testuser:testuser /home/testuser/.dela 28 | 29 | # Copy test files 30 | COPY tests/task_definitions /home/testuser/ 31 | RUN chown -R testuser:testuser /home/testuser 32 | 33 | # Copy the compiled binary from the builder stage 34 | COPY --from=builder /app/target/debug/dela /usr/local/bin/dela 35 | 36 | USER testuser 37 | WORKDIR /home/testuser 38 | 39 | # Set up environment variables 40 | ENV HOME=/home/testuser 41 | ENV SHELL=/bin/bash 42 | ENV PATH="/home/testuser/.local/bin:${PATH}" 43 | 44 | # Entry point script will be mounted 45 | CMD ["bash", "/home/testuser/test_script.sh"] -------------------------------------------------------------------------------- /tests/docker_bash/bashrc.test: -------------------------------------------------------------------------------- 1 | # Basic bash configuration for testing 2 | 3 | # Basic history configuration 4 | HISTFILE=~/.bash_history 5 | HISTSIZE=1000 6 | HISTFILESIZE=2000 7 | 8 | # Basic prompt 9 | PS1='\u@\h:\w\$ ' 10 | 11 | # Basic path 12 | PATH=/usr/local/bin:/usr/bin:/bin:$PATH 13 | export PATH 14 | 15 | # Basic command not found handler (will be replaced by dela) 16 | command_not_found_handle() { 17 | echo "bash: command not found: $1" >&2 18 | return 127 19 | } -------------------------------------------------------------------------------- /tests/docker_bash/test_bash.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit on any error 4 | set -e 5 | 6 | # Default to non-verbose output 7 | VERBOSE=${VERBOSE:-0} 8 | 9 | # Set up logging functions 10 | log() { 11 | if [ "$VERBOSE" = "1" ]; then 12 | echo "$@" 13 | fi 14 | } 15 | 16 | error() { 17 | echo "Error: $@" >&2 18 | } 19 | 20 | # Enable command printing only in verbose mode 21 | if [ "$VERBOSE" = "1" ]; then 22 | set -x 23 | fi 24 | 25 | log "=== Testing dela shell integration for bash ===" 26 | 27 | log "1. Verifying test environment..." 28 | 29 | # Verify dela binary is installed and accessible 30 | which dela || (error "dela not found in PATH" && exit 1) 31 | 32 | # Verify .bashrc exists 33 | test -f ~/.bashrc || (error ".bashrc not found" && exit 1) 34 | 35 | # Verify Makefile exists 36 | test -f ~/Makefile || (error "Makefile not found" && exit 1) 37 | 38 | # Verify initial command_not_found_handle works 39 | source ~/.bashrc 40 | output=$(nonexistent_command 2>&1) || true 41 | if ! echo "$output" | grep -q "bash: command not found: nonexistent_command"; then 42 | error "Initial command_not_found_handle not working." 43 | error "Expected: 'bash: command not found: nonexistent_command'" 44 | error "Got: '$output'" 45 | exit 1 46 | fi 47 | 48 | log "2. Testing dela initialization..." 49 | 50 | # Initialize dela and verify directory creation 51 | dela init 52 | test -d ~/.dela || (error "~/.dela directory not created" && exit 1) 53 | 54 | # Verify shell integration was added 55 | grep -q "eval \"\$(dela configure-shell)\"" ~/.bashrc || { 56 | error "Shell integration not found in .bashrc" 57 | exit 1 58 | } 59 | 60 | log "3. Testing dela shell integration..." 61 | 62 | # Source updated bashrc and check for errors 63 | source ~/.bashrc 64 | if [ $? -ne 0 ]; then 65 | error "Failed to source .bashrc" 66 | exit 1 67 | fi 68 | 69 | # Verify shell integration was loaded 70 | output=$(dela configure-shell 2>&1) 71 | if [ $? -ne 0 ]; then 72 | error "dela configure-shell failed with output: $output" 73 | exit 1 74 | fi 75 | 76 | # Verify the shell integration output contains the correct allowlist check 77 | if ! echo "$output" | grep -q "if ! dela allow-command \"\$1\""; then 78 | error "Shell integration missing allowlist check" 79 | error "Got: $output" 80 | exit 1 81 | fi 82 | 83 | # Test dela list command 84 | log "Testing dela list command..." 85 | # Save the output to a file instead of piping directly to grep 86 | dela list > ~/dela_list_output.txt || true 87 | # Display output for debugging 88 | if [ "$VERBOSE" = "1" ]; then 89 | cat ~/dela_list_output.txt 90 | fi 91 | if grep -q "test-task" ~/dela_list_output.txt; then 92 | log "test-task found in dela list" 93 | else 94 | error "test-task not found in dela list" && exit 1 95 | fi 96 | if grep -q "npm-test" ~/dela_list_output.txt; then 97 | log "npm-test found in dela list" 98 | else 99 | error "npm-test not found in dela list" && exit 1 100 | fi 101 | if grep -q "npm-build" ~/dela_list_output.txt; then 102 | log "npm-build found in dela list" 103 | else 104 | error "npm-build not found in dela list" && exit 1 105 | fi 106 | if grep -q "poetry-build" ~/dela_list_output.txt; then 107 | log "poetry-build found in dela list" 108 | else 109 | error "poetry-build not found in dela list" && exit 1 110 | fi 111 | 112 | log "Testing task shadowing detection..." 113 | 114 | # Create a custom executable in PATH 115 | log "Creating custom executable..." 116 | mkdir -p ~/.local/bin 117 | cat > ~/.local/bin/custom-exe << 'EOF' 118 | #!/bin/sh 119 | echo "Custom executable in PATH" 120 | EOF 121 | chmod +x ~/.local/bin/custom-exe 122 | 123 | # Test that dela list shows shadowing symbols 124 | log "Testing shadow detection in dela list..." 125 | dela list > ~/shadow_list_output.txt || true 126 | if ! grep -q "cd-m.*cd.*†" ~/shadow_list_output.txt; then 127 | error "Shell builtin shadowing symbol not found for 'cd' task" 128 | cat ~/shadow_list_output.txt 129 | exit 1 130 | fi 131 | 132 | # Check for PATH executable shadowing (custom-exe) 133 | if ! grep -q "custom-exe-m.*custom-exe.*‡" ~/shadow_list_output.txt; then 134 | error "PATH executable shadowing symbol not found for 'custom-exe' task" 135 | cat ~/shadow_list_output.txt 136 | exit 1 137 | fi 138 | 139 | log "4. Testing task disambiguation..." 140 | 141 | # Extract disambiguated task names from the main listing 142 | log "Searching for test- entries:" 143 | grep -E 'test-[^ ]+' ~/dela_list_output.txt || log "No test- entries found!" 144 | 145 | # Skip detailed disambiguation test - this is fully tested in test_noinit.sh 146 | log "Skipping detailed disambiguation test" 147 | 148 | # Add test for column width consistency 149 | log "Testing column width formatting consistency..." 150 | 151 | # Simplify the column width test - just verify basic formatting 152 | dela list > ~/task_list_output.txt || true 153 | 154 | # Count total number of task lines 155 | total_lines=$(grep -E "^ [^ ]+" ~/task_list_output.txt | wc -l) 156 | log "Found $total_lines task lines for column width check" 157 | 158 | if [ "$total_lines" -lt 10 ]; then 159 | error "Expected at least 10 task lines, but found only $total_lines" 160 | cat ~/task_list_output.txt 161 | exit 1 162 | fi 163 | 164 | # Just verify all task lines start with 2 spaces followed by a non-space character 165 | # followed by spaces, and have consistent column alignment 166 | column_widths=$(grep -E "^ [^ ]+" ~/task_list_output.txt | awk '{print length($1)}' | sort | uniq | wc -l) 167 | if [ "$column_widths" -gt 15 ]; then 168 | error "Column widths are not consistent (found more than 15 different widths)" 169 | cat ~/task_list_output.txt 170 | exit 1 171 | fi 172 | 173 | log "Column width formatting test passed successfully" 174 | 175 | # Clean up the test file 176 | rm -f ~/task_list_output.txt ~/dela_list_output.txt ~/shadow_list_output.txt 177 | 178 | log "5. Testing allowlist functionality..." 179 | 180 | # Ensure we're in non-interactive mode for allowlist testing 181 | export DELA_NON_INTERACTIVE=1 182 | 183 | # Test that task is initially not allowed 184 | log "Testing task is initially blocked..." 185 | output=$(test-task 2>&1) || true 186 | if ! echo "$output" | grep -q "requires approval"; then 187 | error "Expected task to be blocked with approval prompt, but got: $output" 188 | exit 1 189 | fi 190 | 191 | # Test interactive allow-command functionality 192 | log "Testing interactive allow-command functionality..." 193 | unset DELA_NON_INTERACTIVE 194 | unset DELA_AUTO_ALLOW 195 | echo "2" | dela allow-command test-task || (error "Failed to allow test-task" && exit 1) 196 | 197 | # Reload shell integration again 198 | source ~/.bashrc 199 | 200 | # Verify task is now allowed and runs 201 | log "Testing allowed task execution..." 202 | output=$(test-task 2>&1) 203 | if ! echo "$output" | grep -q "Test task executed successfully"; then 204 | error "Task execution failed. Got: $output" 205 | exit 1 206 | fi 207 | 208 | # Test UV tasks with non-interactive mode 209 | log "Testing UV tasks with non-interactive mode..." 210 | export DELA_NON_INTERACTIVE=1 211 | dela allow-command uv-test --allow 2 || (error "Failed to allow uv-test" && exit 1) 212 | dela allow-command uv-build --allow 2 || (error "Failed to allow uv-build" && exit 1) 213 | 214 | output=$(dr uv-test 2>&1) 215 | if ! echo "$output" | grep -q "Test task executed successfully"; then 216 | error "dr uv-test failed. Got: $output" 217 | exit 1 218 | fi 219 | 220 | output=$(dr uv-build 2>&1) 221 | if ! echo "$output" | grep -q "Build task executed successfully"; then 222 | error "dr uv-build failed. Got: $output" 223 | exit 1 224 | fi 225 | 226 | # Test Poetry tasks with non-interactive mode 227 | log "Testing Poetry tasks with non-interactive mode..." 228 | dela allow-command poetry-test --allow 2 || (error "Failed to allow poetry-test" && exit 1) 229 | dela allow-command poetry-build --allow 2 || (error "Failed to allow poetry-build" && exit 1) 230 | 231 | output=$(dr poetry-test 2>&1) 232 | if ! echo "$output" | grep -q "Test task executed successfully"; then 233 | error "dr poetry-test failed. Got: $output" 234 | exit 1 235 | fi 236 | 237 | output=$(dr poetry-build 2>&1) 238 | if ! echo "$output" | grep -q "Build task executed successfully"; then 239 | error "dr poetry-build failed. Got: $output" 240 | exit 1 241 | fi 242 | 243 | # Verify command_not_found_handle was properly replaced 244 | log "Testing final command_not_found_handle..." 245 | # Temporarily disable trace mode for this test 246 | set +x 247 | output=$(nonexistent_command 2>&1) || true 248 | if ! echo "$output" | grep -q "dela: command or task not found: nonexistent_command"; then 249 | error "Command not found handler not properly replaced" 250 | error "Expected: 'dela: command or task not found: nonexistent_command'" 251 | error "Got: $output" 252 | exit 1 253 | fi 254 | 255 | # Test argument passing 256 | log "Testing argument passing to tasks..." 257 | 258 | # Test single argument passing 259 | log "Testing single argument passing..." 260 | dela allow-command print-arg-task --allow 2 || (error "Failed to allow print-arg-task" && exit 1) 261 | 262 | output=$(ARG=value1 dr print-arg-task) 263 | if ! echo "$output" | grep -q "Argument is: value1"; then 264 | error "Single argument not passed correctly" 265 | error "Expected: Argument is: value1" 266 | error "Got: $output" 267 | exit 1 268 | fi 269 | 270 | # Test multiple arguments passing 271 | log "Testing multiple arguments passing..." 272 | dela allow-command print-args --allow 2 || (error "Failed to allow print-args" && exit 1) 273 | 274 | output=$(ARGS="--flag1 --flag2=value positional" dr print-args) 275 | if ! echo "$output" | grep -q "Arguments passed to print-args:"; then 276 | error "Expected output to contain 'Arguments passed to print-args:'" 277 | exit 1 278 | fi 279 | if ! echo "$output" | grep -q -- "--flag1"; then 280 | error "Expected output to contain '--flag1'" 281 | error "Got: $output" 282 | exit 1 283 | fi 284 | if ! echo "$output" | grep -q -- "--flag2=value"; then 285 | error "Expected output to contain '--flag2=value'" 286 | error "Got: $output" 287 | exit 1 288 | fi 289 | if ! echo "$output" | grep -q "positional"; then 290 | error "Expected output to contain 'positional'" 291 | error "Got: $output" 292 | exit 1 293 | fi 294 | 295 | log "All tests passed!" 296 | 297 | log "=== All tests passed successfully! ===" -------------------------------------------------------------------------------- /tests/docker_fish/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.4 2 | # Build stage using common builder 3 | FROM dela-builder AS builder 4 | 5 | # Test environment 6 | FROM alpine:3.21 7 | 8 | # Install required packages 9 | RUN apk add --no-cache \ 10 | fish \ 11 | make \ 12 | python3 \ 13 | uv \ 14 | poetry \ 15 | npm 16 | 17 | # Create test user 18 | RUN adduser -D -s /bin/fish testuser 19 | 20 | # Set up fish configuration directory and file 21 | RUN mkdir -p /home/testuser/.config/fish 22 | COPY tests/docker_fish/config.fish.test /home/testuser/.config/fish/config.fish 23 | RUN chown -R testuser:testuser /home/testuser/.config && \ 24 | chmod 644 /home/testuser/.config/fish/config.fish 25 | 26 | # Create dela directory with proper permissions 27 | RUN mkdir -p /home/testuser/.dela && \ 28 | chown -R testuser:testuser /home/testuser/.dela 29 | 30 | # Copy test files 31 | COPY tests/task_definitions /home/testuser/ 32 | RUN chown -R testuser:testuser /home/testuser 33 | 34 | # Copy the compiled binary from the builder stage 35 | COPY --from=builder /app/target/debug/dela /usr/local/bin/dela 36 | 37 | USER testuser 38 | WORKDIR /home/testuser 39 | 40 | # Set up environment variables 41 | ENV HOME=/home/testuser 42 | ENV SHELL=/bin/fish 43 | ENV PATH="/home/testuser/.local/bin:${PATH}" 44 | 45 | # Entry point script will be mounted 46 | CMD ["fish", "/home/testuser/test_script.sh"] -------------------------------------------------------------------------------- /tests/docker_fish/config.fish.test: -------------------------------------------------------------------------------- 1 | # Basic fish configuration for testing 2 | 3 | # Set up basic prompt 4 | function fish_prompt 5 | echo -n (whoami)'@'(hostname)':'(pwd)'$ ' 6 | end 7 | 8 | # Set up basic path 9 | set -x PATH /usr/local/bin /usr/bin /bin $PATH 10 | 11 | # Basic command not found handler (will be replaced by dela) 12 | function fish_command_not_found 13 | echo "fish: Unknown command: $argv[1]" >&2 14 | return 127 15 | end 16 | 17 | # Allow sourcing in non-interactive mode 18 | status --is-interactive; or status --is-login; or true 19 | -------------------------------------------------------------------------------- /tests/docker_fish/test_fish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/fish 2 | 3 | # Default to non-verbose output 4 | set -q VERBOSE; or set VERBOSE 0 5 | 6 | # Set up logging functions 7 | function log 8 | test "$VERBOSE" = "1"; and echo $argv 9 | end 10 | 11 | function error 12 | echo "Error: $argv" >&2 13 | exit 1 14 | end 15 | 16 | # Set up error handling 17 | status --is-interactive; and exit 1 18 | 19 | log "=== Testing dela shell integration for fish ===" 20 | 21 | log "1. Verifying test environment..." 22 | 23 | # Verify dela binary is installed and accessible 24 | command -v dela >/dev/null; or begin 25 | error "dela not found in PATH" 26 | exit 1 27 | end 28 | 29 | # Verify config.fish exists 30 | test -f ~/.config/fish/config.fish; or begin 31 | error "config.fish not found" 32 | exit 1 33 | end 34 | 35 | # Verify Makefile exists 36 | test -f ~/Makefile; or begin 37 | error "Makefile not found" 38 | exit 1 39 | end 40 | 41 | # Verify initial command_not_found_handler works 42 | begin 43 | set output (fish -c "nonexistent_command" 2>&1) 44 | or true 45 | end 46 | if not string match -q "*fish: Unknown command: nonexistent_command*" -- "$output" 47 | error "Initial command_not_found_handler not working." 48 | error "Expected: 'fish: Unknown command: nonexistent_command'" 49 | error "Got: '$output'" 50 | exit 1 51 | end 52 | 53 | log "2. Testing dela initialization..." 54 | 55 | # Initialize dela and verify directory creation 56 | dela init 57 | test -d ~/.dela; or begin 58 | error "~/.dela directory not created" 59 | exit 1 60 | end 61 | 62 | # Verify shell integration was added 63 | grep -q "eval (dela configure-shell | string collect)" ~/.config/fish/config.fish; or begin 64 | error "Shell integration not found in config.fish" 65 | exit 1 66 | end 67 | 68 | log "3. Testing dela shell integration..." 69 | 70 | # Source updated config.fish and check for errors 71 | source ~/.config/fish/config.fish 72 | if test $status -ne 0 73 | error "Failed to source config.fish" 74 | exit 1 75 | end 76 | 77 | # Verify shell integration was loaded 78 | set output (dela configure-shell 2>&1) 79 | if test $status -ne 0 80 | error "dela configure-shell failed with output: $output" 81 | exit 1 82 | end 83 | 84 | # Test dela list command 85 | log "Testing dela list command..." 86 | dela list > ~/dela_list_output.txt || true 87 | if not grep -q "test-task" ~/dela_list_output.txt 88 | error "test-task not found in dela list" 89 | exit 1 90 | end 91 | if not grep -q "npm-test" ~/dela_list_output.txt 92 | error "npm-test not found in dela list" 93 | exit 1 94 | end 95 | if not grep -q "npm-build" ~/dela_list_output.txt 96 | error "npm-build not found in dela list" 97 | exit 1 98 | end 99 | if not grep -q "poetry-build" ~/dela_list_output.txt 100 | error "poetry-build not found in dela list" 101 | exit 1 102 | end 103 | 104 | log "Testing task shadowing detection..." 105 | 106 | # Create a custom executable in PATH 107 | log "Creating custom executable..." 108 | mkdir -p ~/.local/bin 109 | echo '#!/bin/sh' > ~/.local/bin/custom-exe 110 | echo 'echo "Custom executable in PATH"' >> ~/.local/bin/custom-exe 111 | chmod +x ~/.local/bin/custom-exe 112 | 113 | # Test that dela list shows shadowing symbols 114 | log "Testing shadow detection in dela list..." 115 | dela list > ~/shadow_list_output.txt || true 116 | if not string match -q "*cd-m*cd*†*" (cat ~/shadow_list_output.txt) 117 | error "Shell builtin shadowing symbol not found for 'cd' task" 118 | cat ~/shadow_list_output.txt 119 | exit 1 120 | end 121 | 122 | # Check for PATH executable shadowing (custom-exe) 123 | if not string match -q "*custom-exe-m*custom-exe*‡*" (cat ~/shadow_list_output.txt) 124 | error "PATH executable shadowing symbol not found for 'custom-exe' task" 125 | cat ~/shadow_list_output.txt 126 | exit 1 127 | end 128 | 129 | log "4. Testing task disambiguation..." 130 | 131 | # Extract disambiguated task names from the main listing 132 | log "Searching for test- entries:" 133 | grep -E 'test-[^ ]+' ~/dela_list_output.txt || log "No test- entries found!" 134 | 135 | # Skip detailed disambiguation test - this is fully tested in test_noinit.sh 136 | log "Skipping detailed disambiguation test" 137 | 138 | log "5. Testing allowlist functionality..." 139 | 140 | # Ensure we're in non-interactive mode for allowlist testing 141 | set -x DELA_NON_INTERACTIVE 1 142 | 143 | # Test that task is initially blocked 144 | log "Testing task is initially blocked..." 145 | set output (fish -c "test-task" 2>&1); or true 146 | if not string match -q "*requires approval*" -- "$output" 147 | error "Expected task to be blocked with approval prompt, but got: $output" 148 | exit 1 149 | end 150 | 151 | # Test interactive allow-command functionality 152 | log "Testing interactive allow-command functionality..." 153 | set -e DELA_NON_INTERACTIVE 154 | printf "2\n" | dela allow-command test-task >/dev/null 2>&1; or error "Failed to allow test-task" 155 | 156 | # Test allowed task execution 157 | log "Testing allowed task execution..." 158 | source ~/.config/fish/config.fish 159 | eval (dela configure-shell | string collect) 160 | 161 | # Create a temporary script to run the command 162 | echo '#!/usr/bin/fish 163 | dr test-task' > ~/run_test.fish 164 | chmod +x ~/run_test.fish 165 | set output (~/run_test.fish 2>&1) 166 | rm ~/run_test.fish 167 | 168 | if not string match -q "*Test task executed successfully*" -- "$output" 169 | error "Task execution failed after allowing. Got: $output" 170 | exit 1 171 | end 172 | 173 | # Test UV tasks with non-interactive mode 174 | log "Testing UV tasks with non-interactive mode..." 175 | set -x DELA_NON_INTERACTIVE 1 176 | dela allow-command uv-test --allow 2 >/dev/null 2>&1; or error "Failed to allow uv-test" 177 | dela allow-command uv-build --allow 2 >/dev/null 2>&1; or error "Failed to allow uv-build" 178 | 179 | # Create a temporary script for UV test 180 | echo '#!/usr/bin/fish 181 | dr uv-test' > ~/run_uv_test.fish 182 | chmod +x ~/run_uv_test.fish 183 | set output (~/run_uv_test.fish 2>&1) 184 | rm ~/run_uv_test.fish 185 | 186 | if not string match -q "*Test task executed successfully*" -- "$output" 187 | error "UV test task failed. Got: $output" 188 | exit 1 189 | end 190 | 191 | # Create a temporary script for UV build 192 | echo '#!/usr/bin/fish 193 | dr uv-build' > ~/run_uv_build.fish 194 | chmod +x ~/run_uv_build.fish 195 | set output (~/run_uv_build.fish 2>&1) 196 | rm ~/run_uv_build.fish 197 | 198 | if not string match -q "*Build task executed successfully*" -- "$output" 199 | error "UV build task failed. Got: $output" 200 | exit 1 201 | end 202 | 203 | # Test Poetry tasks with non-interactive mode 204 | log "Testing Poetry tasks with non-interactive mode..." 205 | dela allow-command poetry-test --allow 2 >/dev/null 2>&1; or error "Failed to allow poetry-test" 206 | dela allow-command poetry-build --allow 2 >/dev/null 2>&1; or error "Failed to allow poetry-build" 207 | 208 | # Create a temporary script for Poetry test 209 | echo '#!/usr/bin/fish 210 | dr poetry-test' > ~/run_poetry_test.fish 211 | chmod +x ~/run_poetry_test.fish 212 | set output (~/run_poetry_test.fish 2>&1) 213 | rm ~/run_poetry_test.fish 214 | 215 | if not string match -q "*Test task executed successfully*" -- "$output" 216 | error "Poetry test task failed. Got: $output" 217 | exit 1 218 | end 219 | 220 | # Create a temporary script for Poetry build 221 | echo '#!/usr/bin/fish 222 | dr poetry-build' > ~/run_poetry_build.fish 223 | chmod +x ~/run_poetry_build.fish 224 | set output (~/run_poetry_build.fish 2>&1) 225 | rm ~/run_poetry_build.fish 226 | 227 | if not string match -q "*Build task executed successfully*" -- "$output" 228 | error "Poetry build task failed. Got: $output" 229 | exit 1 230 | end 231 | 232 | # Verify command_not_found_handler was properly replaced 233 | log "Testing final command_not_found_handler..." 234 | set output (fish -c "nonexistent_command" 2>&1); or true 235 | if not string match -q "*fish: Unknown command: nonexistent_command*" -- "$output" 236 | error "Command not found handler wasn't properly replaced." 237 | error "Got: '$output'" 238 | exit 1 239 | end 240 | 241 | # Test column width formatting with a very long task name 242 | log "Testing column width formatting consistency..." 243 | 244 | # Simplify the column width test - just verify basic formatting 245 | dela list > ~/task_list_output.txt || true 246 | 247 | # Count total number of task lines 248 | set total_lines (grep -E "^ [^ ]+" ~/task_list_output.txt | wc -l) 249 | log "Found $total_lines task lines for column width check" 250 | 251 | if test $total_lines -lt 10 252 | error "Expected at least 10 task lines, but found only $total_lines" 253 | cat ~/task_list_output.txt 254 | exit 1 255 | end 256 | 257 | # Just verify all task lines start with 2 spaces followed by a non-space character 258 | # followed by spaces, and have consistent column alignment 259 | set column_widths (grep -E "^ [^ ]+" ~/task_list_output.txt | awk '{print length($1)}' | sort | uniq | wc -l) 260 | if test $column_widths -gt 15 261 | error "Column widths are not consistent (found more than 15 different widths)" 262 | cat ~/task_list_output.txt 263 | exit 1 264 | end 265 | 266 | log "Column width formatting test passed successfully" 267 | 268 | # Clean up the test files 269 | rm -f ~/task_list_output.txt ~/dela_list_output.txt ~/shadow_list_output.txt 270 | 271 | # Test arguments are passed to tasks 272 | log "Testing argument passing to tasks..." 273 | # First allow the command - using --allow 2 to automatically approve it 274 | set -x DELA_NON_INTERACTIVE 1 275 | dela allow-command print-args --allow 2 >/dev/null 2>&1 276 | set -e DELA_NON_INTERACTIVE 277 | if test $status -ne 0 278 | error "Failed to allow print-args" 279 | exit 1 280 | end 281 | 282 | # Test argument passing via environment variable 283 | log "Testing argument passing via environment variable..." 284 | set -x ARGS "--arg1 --arg2 value" 285 | set -l output (dr print-args 2>&1) 286 | set -e ARGS 287 | 288 | # Print the output for debugging 289 | log "Command output: $output" 290 | 291 | if not string match -q "*Arguments passed to print-args: --arg1 --arg2 value*" -- "$output" 292 | echo "Full output: $output" 293 | error "Arguments not passed correctly through dr command" 294 | error "Expected: Arguments passed to print-args: --arg1 --arg2 value" 295 | error "Got: $output" 296 | exit 1 297 | end 298 | 299 | # Clean up test files 300 | rm -f ~/task_list_output.txt ~/dela_list_output.txt ~/shadow_list_output.txt 301 | 302 | log "=== All tests passed successfully! ===" 303 | exit 0 -------------------------------------------------------------------------------- /tests/docker_noinit/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.4 2 | # Build stage using common builder 3 | FROM dela-builder AS builder 4 | 5 | # Test environment 6 | FROM alpine:3.21 7 | 8 | # Install required packages 9 | RUN apk add --no-cache \ 10 | zsh \ 11 | make \ 12 | python3 \ 13 | uv \ 14 | poetry \ 15 | nodejs \ 16 | npm \ 17 | task \ 18 | maven \ 19 | gradle 20 | 21 | # Install act (GitHub Actions runner) 22 | # TODO: Get act from the package manager, once it is in a non-edge alpine release. 23 | RUN apk add --no-cache curl git bash && \ 24 | mkdir -p /tmp/act-installation && \ 25 | cd /tmp/act-installation && \ 26 | curl -s https://raw.githubusercontent.com/nektos/act/master/install.sh | bash && \ 27 | mv ./bin/act /usr/local/bin/ && \ 28 | chmod +x /usr/local/bin/act && \ 29 | cd / && \ 30 | rm -rf /tmp/act-installation 31 | 32 | # Create test user 33 | RUN adduser -D -s /bin/zsh testuser 34 | 35 | # Create test directories and dela config 36 | RUN mkdir -p /home/testuser/test_project /home/testuser/.dela && \ 37 | chown -R testuser:testuser /home/testuser && \ 38 | chmod -R 755 /home/testuser/.dela 39 | 40 | # Create initial allowlist with only npm-test 41 | RUN echo 'entries = [' > /home/testuser/.dela/allowlist.toml && \ 42 | echo ' { path = "/home/testuser/test_project/package.json", scope = "Task", tasks = ["npm-test"] }' >> /home/testuser/.dela/allowlist.toml && \ 43 | echo ']' >> /home/testuser/.dela/allowlist.toml && \ 44 | chown testuser:testuser /home/testuser/.dela/allowlist.toml && \ 45 | chmod 644 /home/testuser/.dela/allowlist.toml 46 | 47 | # Create task definitions file 48 | RUN echo "tasks:" > /home/testuser/test_project/task_definitions.yml && \ 49 | echo " test-task:" >> /home/testuser/test_project/task_definitions.yml && \ 50 | echo " cmd: echo \"Test task executed successfully\"" >> /home/testuser/test_project/task_definitions.yml && \ 51 | echo " description: \"A test task\"" >> /home/testuser/test_project/task_definitions.yml 52 | 53 | # Copy test files 54 | COPY tests/task_definitions/Makefile /home/testuser/test_project/ 55 | COPY tests/task_definitions/package.json /home/testuser/test_project/ 56 | COPY tests/task_definitions/pyproject.toml /home/testuser/test_project/ 57 | COPY tests/task_definitions/uv.lock /home/testuser/test_project/ 58 | COPY tests/task_definitions/Taskfile.yml /home/testuser/test_project/ 59 | COPY tests/task_definitions/pom.xml /home/testuser/test_project/ 60 | COPY tests/task_definitions/build.gradle /home/testuser/test_project/ 61 | COPY tests/task_definitions/build.gradle.kts /home/testuser/test_project/ 62 | # Copy GitHub Actions workflow files 63 | COPY tests/task_definitions/github_actions/.github /home/testuser/test_project/.github 64 | RUN chown -R testuser:testuser /home/testuser/test_project 65 | 66 | # Copy the compiled binary from the builder stage 67 | COPY --from=builder /app/target/debug/dela /usr/local/bin/ 68 | 69 | USER testuser 70 | WORKDIR /home/testuser/test_project 71 | 72 | # Set up environment variables 73 | ENV HOME=/home/testuser 74 | ENV SHELL=/bin/zsh 75 | ENV PATH="/home/testuser/.local/bin:${PATH}" 76 | 77 | # Entry point script will be mounted 78 | CMD ["zsh", "/home/testuser/test_script.sh"] 79 | -------------------------------------------------------------------------------- /tests/docker_pwsh/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.4 2 | # Build stage using common builder 3 | FROM dela-builder AS builder 4 | 5 | # Test environment 6 | FROM alpine:3.21 7 | 8 | # Install required packages 9 | RUN apk add --no-cache \ 10 | powershell \ 11 | make \ 12 | python3 \ 13 | uv \ 14 | poetry \ 15 | npm 16 | 17 | # Create test user 18 | RUN adduser -D -s /bin/pwsh testuser 19 | 20 | # Create PowerShell profile directory and set up profile 21 | RUN mkdir -p /home/testuser/.config/powershell && \ 22 | chown -R testuser:testuser /home/testuser/.config 23 | 24 | # Set up PowerShell profile 25 | COPY --chown=testuser:testuser tests/docker_pwsh/Microsoft.PowerShell_profile.ps1 /home/testuser/.config/powershell/Microsoft.PowerShell_profile.ps1 26 | RUN chmod 644 /home/testuser/.config/powershell/Microsoft.PowerShell_profile.ps1 27 | 28 | # Create dela directory with proper permissions 29 | RUN mkdir -p /home/testuser/.dela && \ 30 | chown -R testuser:testuser /home/testuser/.dela 31 | 32 | # Copy test files 33 | COPY tests/task_definitions /home/testuser/ 34 | RUN chown -R testuser:testuser /home/testuser 35 | 36 | # Set working directory 37 | WORKDIR /home/testuser 38 | 39 | # Copy dela binary 40 | COPY --from=builder /app/target/debug/dela /usr/local/bin/dela 41 | 42 | # Set shell environment variable 43 | ENV SHELL=/bin/pwsh 44 | 45 | # Switch to test user 46 | USER testuser 47 | 48 | # Run the test script 49 | CMD ["pwsh", "./test_script.ps1"] -------------------------------------------------------------------------------- /tests/docker_pwsh/Microsoft.PowerShell_profile.ps1: -------------------------------------------------------------------------------- 1 | # Basic PowerShell profile for testing 2 | 3 | # Set a basic prompt 4 | function prompt { 5 | "$($PWD.Path)> " 6 | } 7 | 8 | # Set up basic path 9 | $env:PATH = "/usr/local/bin:/usr/bin:/bin:$env:PATH" 10 | 11 | # Basic command not found handler (will be replaced by dela) 12 | trap [System.Management.Automation.CommandNotFoundException] { 13 | Write-Error "pwsh: command not found: $($_.CategoryInfo.TargetName)" 14 | continue 15 | } -------------------------------------------------------------------------------- /tests/docker_pwsh/test_pwsh.ps1: -------------------------------------------------------------------------------- 1 | # Exit on any error 2 | $ErrorActionPreference = "Stop" 3 | 4 | # Default to non-verbose output 5 | if (-not $env:VERBOSE) { 6 | $env:VERBOSE = "0" 7 | } 8 | 9 | # Set up logging functions 10 | function Write-Log { 11 | param([string]$Message) 12 | if ($env:VERBOSE -eq "1") { 13 | Write-Host $Message 14 | } 15 | } 16 | 17 | function Write-Error { 18 | param([string]$Message) 19 | [Console]::Error.WriteLine("Error: $Message") 20 | exit 1 21 | } 22 | 23 | Write-Log "=== Testing dela shell integration for PowerShell ===" 24 | 25 | Write-Log "1. Verifying test environment..." 26 | 27 | # Verify dela binary is installed and accessible 28 | if (-not (Get-Command dela -ErrorAction SilentlyContinue)) { 29 | Write-Error "dela not found in PATH" 30 | } 31 | 32 | # Verify PowerShell profile exists 33 | $profilePath = "$HOME/.config/powershell/Microsoft.PowerShell_profile.ps1" 34 | if (-not (Test-Path $profilePath)) { 35 | Write-Error "PowerShell profile not found at $profilePath" 36 | } 37 | 38 | # Verify Makefile exists 39 | if (-not (Test-Path ~/Makefile)) { 40 | Write-Error "Makefile not found" 41 | } 42 | 43 | # Verify initial command not found handler works 44 | try { 45 | nonexistent_command 46 | } catch { 47 | $output = $_.Exception.Message 48 | if (-not ($output -match "The term 'nonexistent_command' is not recognized")) { 49 | Write-Error "Initial command_not_found_handler not working.`nExpected PowerShell error message for unrecognized command`nGot: '$output'" 50 | } 51 | } 52 | 53 | Write-Log "2. Testing dela initialization..." 54 | 55 | # Initialize dela and verify directory creation 56 | dela init 57 | if (-not (Test-Path ~/.dela)) { 58 | Write-Error "~/.dela directory not created" 59 | } 60 | 61 | # Verify shell integration was added 62 | $profileContent = Get-Content $profilePath -Raw 63 | if (-not ($profileContent -match [regex]::Escape('Invoke-Expression (dela configure-shell | Out-String)'))) { 64 | Write-Error "Shell integration not found in PowerShell profile" 65 | } 66 | 67 | Write-Log "3. Testing dela shell integration..." 68 | 69 | # Source updated profile and check for errors 70 | try { 71 | . $profilePath 72 | } catch { 73 | Write-Error "Failed to source PowerShell profile: $_" 74 | } 75 | 76 | # Verify shell integration was loaded 77 | try { 78 | $output = dela configure-shell 2>&1 79 | if ($output -is [array]) { 80 | $output = $output -join "`n" 81 | } 82 | Invoke-Expression $output 83 | } catch { 84 | Write-Error "dela configure-shell failed with output: $_" 85 | } 86 | 87 | # Test dela list command 88 | Write-Log "Testing dela list command..." 89 | $listOutput = dela list 90 | Write-Host "Debug - dela list output:" 91 | Write-Host $listOutput 92 | Write-Host "Debug - End of dela list output" 93 | if (-not ($listOutput -match "test-task")) { 94 | Write-Error "test-task not found in dela list" 95 | } 96 | if (-not ($listOutput -match "npm-test")) { 97 | Write-Error "npm-test not found in dela list" 98 | } 99 | if (-not ($listOutput -match "npm-build")) { 100 | Write-Error "npm-build not found in dela list" 101 | } 102 | 103 | if (!(dela list | Select-String -Quiet "poetry-build")) { 104 | Write-Error "poetry-build not found in dela list" 105 | exit 1 106 | } 107 | 108 | Write-Log "Testing task shadowing detection..." 109 | 110 | # Create a custom executable in PATH 111 | Write-Log "Creating custom executable..." 112 | $localBinPath = Join-Path $HOME ".local" "bin" 113 | if (-not (Test-Path $localBinPath)) { 114 | New-Item -ItemType Directory -Path $localBinPath -Force | Out-Null 115 | } 116 | 117 | # Create a custom executable 118 | $customExePath = Join-Path $localBinPath "custom-exe" 119 | Set-Content -Path $customExePath -Value @" 120 | #!/bin/sh 121 | echo "I am a custom executable" 122 | "@ 123 | 124 | # Make the file executable using chmod (since we're in a Linux container) 125 | & chmod +x $customExePath 126 | 127 | # Add ~/.local/bin to PATH if not already present 128 | $localBinPath = (Resolve-Path $localBinPath).Path 129 | if (-not ($env:PATH -split ':' -contains $localBinPath)) { 130 | $env:PATH = "${localBinPath}:$env:PATH" 131 | } 132 | 133 | # Verify the executable exists and is executable 134 | if (-not (Test-Path $customExePath)) { 135 | Write-Error "Failed to create custom executable at $customExePath" 136 | } 137 | 138 | Write-Log "Testing if custom-exe is in PATH..." 139 | Write-Log "Current PATH: $env:PATH" 140 | Write-Log "Executable path: $customExePath" 141 | $customExeExists = Get-Command custom-exe -ErrorAction SilentlyContinue 142 | if (-not $customExeExists) { 143 | Write-Error "custom-exe not found in PATH" 144 | } 145 | 146 | # Test that dela list shows shadowing symbols 147 | Write-Log "Testing shadow detection in dela list..." 148 | $output = dela list | Out-String 149 | 150 | # Check the output with different patterns since PowerShell handles matches differently 151 | 152 | # Debug the full output 153 | Write-Host "Debug - dela list output:" 154 | Write-Host $output 155 | Write-Host "Debug - End of dela list output" 156 | 157 | # Check for shell builtin shadowing (cd) 158 | $cdShadowMatch = $output -match "cd.*†" 159 | if (-not $cdShadowMatch) { 160 | # Try alternative format 161 | $cdShadowMatch = $output -match "cd-m.*cd.*†" 162 | } 163 | 164 | if (-not $cdShadowMatch) { 165 | Write-Error "Shell builtin shadowing symbol not found for 'cd' task" 166 | Write-Error "Got output: $output" 167 | exit 1 168 | } 169 | 170 | # Check for PATH executable shadowing (custom-exe) 171 | $customExeMatch = $output -match "custom-exe.*‡" 172 | if (-not $customExeMatch) { 173 | # Try alternative format 174 | $customExeMatch = $output -match "custom-exe-m.*custom-exe.*‡" 175 | } 176 | 177 | if (-not $customExeMatch) { 178 | Write-Error "PATH executable shadowing symbol not found for 'custom-exe' task" 179 | Write-Error "Got output: $output" 180 | exit 1 181 | } 182 | 183 | Write-Log "4. Testing task disambiguation..." 184 | 185 | # Get output from dela list 186 | $listOutput = dela list 187 | 188 | # Extract disambiguated task names from the main listing 189 | Write-Log "Searching for test- entries:" 190 | $testEntries = $listOutput | Select-String -Pattern 'test-[^ ]+' 191 | if ($testEntries) { 192 | Write-Log $testEntries 193 | } else { 194 | Write-Log "No test- entries found!" 195 | } 196 | 197 | # Skip detailed disambiguation test - this is fully tested in test_noinit.sh 198 | Write-Log "Skipping detailed disambiguation test" 199 | 200 | Write-Log "5. Testing allowlist functionality..." 201 | 202 | Write-Log "Testing task execution..." 203 | 204 | # Test interactive allow-command functionality 205 | Write-Log "Testing interactive allow-command functionality..." 206 | $env:DELA_NON_INTERACTIVE = 0 207 | "2" | dela allow-command uv-test 208 | if ($LASTEXITCODE -ne 0) { 209 | Write-Error "Failed to allow uv-test" 210 | } 211 | 212 | # Test non-interactive allow-command 213 | Write-Log "Testing non-interactive allow-command..." 214 | $env:DELA_NON_INTERACTIVE = 1 215 | dela allow-command uv-build --allow 2 216 | if ($LASTEXITCODE -ne 0) { 217 | Write-Error "Failed to allow uv-build" 218 | } 219 | 220 | $output = dr uv-test 221 | if (-not ($output -match "Test task executed successfully")) { 222 | Write-Error "dr uv-test failed. Got: $output" 223 | } 224 | 225 | $output = dr uv-build 226 | if (-not ($output -match "Build task executed successfully")) { 227 | Write-Error "dr uv-build failed. Got: $output" 228 | } 229 | 230 | # Test Poetry tasks with non-interactive mode 231 | Write-Log "Testing Poetry tasks with non-interactive mode..." 232 | dela allow-command poetry-test --allow 2 233 | if ($LASTEXITCODE -ne 0) { 234 | Write-Error "Failed to allow poetry-test" 235 | } 236 | dela allow-command poetry-build --allow 2 237 | if ($LASTEXITCODE -ne 0) { 238 | Write-Error "Failed to allow poetry-build" 239 | } 240 | 241 | $output = dr poetry-test 242 | if (-not ($output -match "Test task executed successfully")) { 243 | Write-Error "dr poetry-test failed. Got: $output" 244 | } 245 | 246 | $output = dr poetry-build 247 | if (-not ($output -match "Build task executed successfully")) { 248 | Write-Error "dr poetry-build failed. Got: $output" 249 | } 250 | 251 | # Verify command not found handler was properly replaced 252 | Write-Log "Testing final command_not_found_handler..." 253 | try { 254 | nonexistent_command 255 | Write-Error "Command not found handler didn't throw an error as expected" 256 | } catch { 257 | $output = $_.Exception.Message 258 | if (-not ($output -match "The term 'nonexistent_command' is not recognized")) { 259 | Write-Error "Command not found handler wasn't properly replaced.`nGot: '$output'" 260 | } 261 | } 262 | 263 | # Test argument passing 264 | Write-Log "Testing argument passing..." 265 | 266 | # Test argument passing with dela get-command 267 | Write-Log "Testing dela get-command argument passing..." 268 | $output = dela get-command -- npm-test --verbose --no-color 269 | if (-not ($output -match "npm run npm-test --verbose --no-color")) { 270 | Write-Error "Arguments are not passed through get-command.`nExpected: npm run npm-test --verbose --no-color`nGot: $output" 271 | } 272 | 273 | # Test bare task execution with arguments 274 | Write-Log "Testing bare task execution with arguments..." 275 | dela allow-command print-args --allow 4 276 | if ($LASTEXITCODE -ne 0) { 277 | Write-Error "Failed to allow print-args" 278 | } 279 | 280 | # The dr function will execute the command with proper environment variables 281 | $env:ARGS = "arg1 arg2 arg with spaces" 282 | $output = dr print-args 283 | if (-not ($output -match "Arguments passed to print-args: arg1 arg2 arg with spaces")) { 284 | Write-Error "Arguments are not passed through bare task execution.`nExpected output to contain: Arguments passed to print-args: arg1 arg2 arg with spaces`nGot: $output" 285 | } 286 | # Clean up environment variable 287 | Remove-Item Env:\ARGS -ErrorAction SilentlyContinue 288 | 289 | # Test uv-run-arg task that accepts arguments 290 | Write-Log "Testing arg passing with a python task..." 291 | dela allow-command uv-run-arg --allow 2 292 | if ($LASTEXITCODE -ne 0) { 293 | Write-Error "Failed to allow uv-run-arg" 294 | } 295 | 296 | $output = dr uv-run-arg --arg1 value1 --arg2=value2 297 | if (-not ($output -match "Arguments:.*--arg1.*value1.*--arg2=value2")) { 298 | Write-Error "Arguments are not passed through dr function for python task.`nExpected output to contain arguments: --arg1 value1 --arg2=value2`nGot: $output" 299 | } 300 | 301 | Write-Log "=== All tests passed successfully! ===" -------------------------------------------------------------------------------- /tests/docker_unit/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.4 2 | FROM dela-builder AS builder 3 | 4 | # Test environment 5 | FROM rust:alpine3.21 6 | 7 | # Install build dependencies 8 | RUN apk add --no-cache \ 9 | musl-dev \ 10 | gcc \ 11 | make \ 12 | openssl-dev \ 13 | pkgconfig \ 14 | bash 15 | 16 | # Create test user and directory 17 | RUN adduser -D testuser 18 | WORKDIR /home/testuser 19 | 20 | # Copy the cargo registry and target directory from the builder 21 | COPY --from=builder /usr/local/cargo/registry /usr/local/cargo/registry 22 | COPY --from=builder /app/target /home/testuser/target 23 | RUN chmod -R 777 /usr/local/cargo/registry /home/testuser/target 24 | 25 | # Copy project files 26 | COPY Cargo.toml Cargo.lock ./ 27 | COPY src ./src 28 | COPY resources ./resources 29 | 30 | # Set ownership 31 | RUN chown -R testuser:testuser . 32 | 33 | # Switch to test user 34 | USER testuser 35 | 36 | # Entry point script will be mounted 37 | CMD ["bash", "/home/testuser/test_script.sh"] 38 | -------------------------------------------------------------------------------- /tests/docker_unit/test_unit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "Running unit tests using cached dependencies..." 5 | 6 | # Run tests directly with cargo test 7 | # This will automatically use the cached dependencies in target directory 8 | cargo test --all-features -------------------------------------------------------------------------------- /tests/docker_zsh/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.4 2 | # Build stage using common builder 3 | FROM dela-builder AS builder 4 | 5 | # Test environment 6 | FROM alpine:3.21 7 | 8 | # Install required packages 9 | RUN apk add --no-cache \ 10 | zsh \ 11 | make \ 12 | python3 \ 13 | uv \ 14 | poetry \ 15 | npm 16 | 17 | # Create test user 18 | RUN adduser -D -s /bin/zsh testuser 19 | 20 | # Set up basic zsh configuration 21 | COPY tests/docker_zsh/zshrc.test /home/testuser/.zshrc 22 | RUN chown testuser:testuser /home/testuser/.zshrc && \ 23 | chmod 644 /home/testuser/.zshrc 24 | 25 | # Create dela directory with proper permissions 26 | RUN mkdir -p /home/testuser/.dela && \ 27 | chown -R testuser:testuser /home/testuser/.dela 28 | 29 | # Copy test files 30 | COPY tests/task_definitions /home/testuser/ 31 | RUN chown -R testuser:testuser /home/testuser 32 | 33 | # Copy the compiled binary from the builder stage 34 | COPY --from=builder /app/target/debug/dela /usr/local/bin/dela 35 | 36 | USER testuser 37 | WORKDIR /home/testuser 38 | 39 | # Set up environment variables 40 | ENV HOME=/home/testuser 41 | ENV SHELL=/bin/zsh 42 | ENV ZSH_VERSION=5.9 43 | ENV PATH="/home/testuser/.local/bin:${PATH}" 44 | 45 | # Entry point script will be mounted 46 | CMD ["zsh", "/home/testuser/test_script.sh"] -------------------------------------------------------------------------------- /tests/docker_zsh/test_zsh.sh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | # Exit on any error 4 | set -e 5 | 6 | # Default to non-verbose output 7 | VERBOSE=${VERBOSE:-0} 8 | 9 | # Set up logging functions 10 | log() { 11 | if [ "$VERBOSE" = "1" ]; then 12 | echo "$@" 13 | fi 14 | } 15 | 16 | error() { 17 | echo "Error: $@" >&2 18 | } 19 | 20 | # Enable command printing only in verbose mode 21 | if [ "$VERBOSE" = "1" ]; then 22 | set -x 23 | fi 24 | 25 | log "=== Testing dela shell integration for zsh ===" 26 | 27 | log "1. Verifying test environment..." 28 | 29 | # Verify dela binary is installed and accessible 30 | which dela || (error "dela not found in PATH" && exit 1) 31 | 32 | # Verify .zshrc exists 33 | test -f ~/.zshrc || (error ".zshrc not found" && exit 1) 34 | 35 | # Verify Makefile exists 36 | test -f ~/Makefile || (error "Makefile not found" && exit 1) 37 | 38 | # Verify initial command_not_found_handler works 39 | source ~/.zshrc 40 | output=$(nonexistent_command 2>&1) || true 41 | if ! echo "$output" | grep -q "Command not found: nonexistent_command"; then 42 | error "Initial command_not_found_handler not working." 43 | error "Expected: 'Command not found: nonexistent_command'" 44 | error "Got: '$output'" 45 | exit 1 46 | fi 47 | 48 | log "2. Testing dela initialization..." 49 | 50 | # Initialize dela and verify directory creation 51 | dela init 52 | test -d ~/.dela || (error "~/.dela directory not created" && exit 1) 53 | 54 | # Verify shell integration was added 55 | grep -q "eval \"\$(dela configure-shell)\"" ~/.zshrc || { 56 | error "Shell integration not found in .zshrc" 57 | exit 1 58 | } 59 | 60 | log "3. Testing dela shell integration..." 61 | 62 | # Source updated zshrc and check for errors 63 | source ~/.zshrc 64 | if [ $? -ne 0 ]; then 65 | error "Failed to source .zshrc" 66 | exit 1 67 | fi 68 | 69 | # Verify shell integration was loaded 70 | output=$(dela configure-shell 2>&1) 71 | if [ $? -ne 0 ]; then 72 | error "dela configure-shell failed with output: $output" 73 | exit 1 74 | fi 75 | 76 | # Test dela list command 77 | log "Testing dela list command..." 78 | dela list | grep -q "test-task" || (error "test-task not found in dela list" && exit 1) 79 | dela list | grep -q "npm-test" || (error "npm-test not found in dela list" && exit 1) 80 | dela list | grep -q "npm-build" || (error "npm-build not found in dela list" && exit 1) 81 | dela list | grep -q "uv-test" || (error "uv-test not found in dela list" && exit 1) 82 | dela list | grep -q "uv-build" || (error "uv-build not found in dela list" && exit 1) 83 | dela list | grep -q "poetry-test" || (error "poetry-test not found in dela list" && exit 1) 84 | dela list | grep -q "poetry-build" || (error "poetry-build not found in dela list" && exit 1) 85 | 86 | log "Testing task shadowing detection..." 87 | 88 | # Create a custom executable in PATH 89 | log "Creating custom executable..." 90 | mkdir -p ~/.local/bin 91 | cat > ~/.local/bin/custom-exe << 'EOF' 92 | #!/bin/sh 93 | echo "Custom executable in PATH" 94 | EOF 95 | chmod +x ~/.local/bin/custom-exe 96 | 97 | # Test that dela list shows shadowing symbols 98 | log "Testing shadow detection in dela list..." 99 | output=$(dela list) 100 | 101 | # Check for shell builtin shadowing (cd) 102 | if ! echo "$output" | grep -q "cd-m.*cd.*†"; then 103 | error "Shell builtin shadowing symbol not found for 'cd' task" 104 | error "Got output: $output" 105 | exit 1 106 | fi 107 | 108 | # Check for PATH executable shadowing (custom-exe) 109 | if ! echo "$output" | grep -q "custom-exe-m.*custom-exe.*‡"; then 110 | error "PATH executable shadowing symbol not found for 'custom-exe' task" 111 | error "Got output: $output" 112 | exit 1 113 | fi 114 | 115 | log "4. Testing task disambiguation..." 116 | 117 | # Get output from dela list 118 | output=$(dela list) 119 | 120 | # Check if task names have the ambiguous symbol (‖) 121 | if ! echo "$output" | grep -q "test.*‖"; then 122 | error "Ambiguous task symbol not found in dela list output" 123 | error "Got output: $output" 124 | exit 1 125 | fi 126 | 127 | # Check if there are disambiguated test task names 128 | if ! echo "$output" | grep -q "test-[^ ]*"; then 129 | error "Disambiguated test task names not found" 130 | error "Got output: $output" 131 | exit 1 132 | fi 133 | 134 | # Extract disambiguated task names from the main listing 135 | log "Searching for test- entries:" 136 | echo "$output" | grep -E 'test-[^ ]+' || log "No test- entries found!" 137 | 138 | # Skip detailed disambiguation test - this is fully tested in test_noinit.sh 139 | log "Skipping detailed disambiguation test" 140 | 141 | # Allow disambiguated tasks 142 | export DELA_NON_INTERACTIVE=1 143 | 144 | if [ ! -z "$make_test" ]; then 145 | log "Testing Make disambiguated task ($make_test)..." 146 | dela allow-command "$make_test" --allow 2 || (error "Failed to allow $make_test" && exit 1) 147 | output=$(dr "$make_test" 2>&1) 148 | if ! echo "$output" | grep -q "Make test task executed successfully"; then 149 | error "dr $make_test failed. Got: $output" 150 | exit 1 151 | fi 152 | fi 153 | 154 | if [ ! -z "$npm_test" ]; then 155 | log "Testing NPM disambiguated task ($npm_test)..." 156 | dela allow-command "$npm_test" --allow 2 || (error "Failed to allow $npm_test" && exit 1) 157 | output=$(dr "$npm_test" 2>&1) 158 | if ! echo "$output" | grep -q "NPM test task executed successfully"; then 159 | error "dr $npm_test failed. Got: $output" 160 | exit 1 161 | fi 162 | fi 163 | 164 | if [ ! -z "$uv_test" ]; then 165 | log "Testing UV disambiguated task ($uv_test)..." 166 | dela allow-command "$uv_test" --allow 2 || (error "Failed to allow $uv_test" && exit 1) 167 | output=$(dr "$uv_test" 2>&1) 168 | if ! echo "$output" | grep -q "Test task executed successfully"; then 169 | error "dr $uv_test failed. Got: $output" 170 | exit 1 171 | fi 172 | fi 173 | 174 | log "5. Testing allowlist functionality..." 175 | 176 | # Ensure we're in non-interactive mode for allowlist testing 177 | export DELA_NON_INTERACTIVE=1 178 | 179 | # Test that task is initially not allowed 180 | log "Testing task is initially blocked..." 181 | output=$(test-task 2>&1) || true 182 | if ! echo "$output" | grep -q "requires approval"; then 183 | error "Expected task to be blocked with approval prompt, but got: $output" 184 | exit 1 185 | fi 186 | 187 | # Test interactive allow-command functionality 188 | log "Testing interactive allow-command functionality..." 189 | unset DELA_NON_INTERACTIVE 190 | unset DELA_AUTO_ALLOW 191 | echo "2" | dela allow-command test-task || (error "Failed to allow test-task" && exit 1) 192 | 193 | # Reload shell integration again 194 | source ~/.zshrc 195 | 196 | # Verify task is now allowed and runs 197 | log "Testing allowed task execution..." 198 | output=$(test-task 2>&1) 199 | if ! echo "$output" | grep -q "Test task executed successfully"; then 200 | error "Task execution failed. Got: $output" 201 | exit 1 202 | fi 203 | 204 | # Test UV tasks with non-interactive mode 205 | log "Testing UV tasks with non-interactive mode..." 206 | export DELA_NON_INTERACTIVE=1 207 | dela allow-command uv-test --allow 2 || (error "Failed to allow uv-test" && exit 1) 208 | dela allow-command uv-build --allow 2 || (error "Failed to allow uv-build" && exit 1) 209 | 210 | output=$(dr uv-test 2>&1) 211 | if ! echo "$output" | grep -q "Test task executed successfully"; then 212 | error "dr uv-test failed. Got: $output" 213 | exit 1 214 | fi 215 | 216 | output=$(dr uv-build 2>&1) 217 | if ! echo "$output" | grep -q "Build task executed successfully"; then 218 | error "dr uv-build failed. Got: $output" 219 | exit 1 220 | fi 221 | 222 | # Test Poetry tasks with non-interactive mode 223 | log "Testing Poetry tasks with non-interactive mode..." 224 | dela allow-command poetry-test --allow 2 || (error "Failed to allow poetry-test" && exit 1) 225 | dela allow-command poetry-build --allow 2 || (error "Failed to allow poetry-build" && exit 1) 226 | 227 | output=$(dr poetry-test 2>&1) 228 | if ! echo "$output" | grep -q "Test task executed successfully"; then 229 | error "dr poetry-test failed. Got: $output" 230 | exit 1 231 | fi 232 | 233 | output=$(dr poetry-build 2>&1) 234 | if ! echo "$output" | grep -q "Build task executed successfully"; then 235 | error "dr poetry-build failed. Got: $output" 236 | exit 1 237 | fi 238 | 239 | # Verify command_not_found_handler was properly replaced 240 | log "Testing final command_not_found_handler..." 241 | output=$(nonexistent_command 2>&1) || true 242 | if echo "$output" | grep -q "Command not found: nonexistent_command"; then 243 | error "Command not found handler wasn't properly replaced." 244 | error "Got: '$output'" 245 | exit 1 246 | fi 247 | 248 | # Test single argument passing 249 | log "Testing single argument passing..." 250 | dela allow-command print-arg-task --allow 2 || (error "Failed to allow print-arg-task" && exit 1) 251 | 252 | output=$(dr print-arg-task ARG=value1) 253 | if ! echo "$output" | grep -q "Argument is: value1"; then 254 | error "Single argument not passed correctly" 255 | error "Expected: Argument is: value1" 256 | error "Got: $output" 257 | exit 1 258 | fi 259 | 260 | # Test multiple arguments passing 261 | log "Testing multiple arguments passing..." 262 | dela allow-command print-args --allow 2 || (error "Failed to allow print-args" && exit 1) 263 | 264 | output=$(dr print-args "ARGS='--flag1 --flag2=value positional'") 265 | if ! echo "$output" | grep -q "Arguments passed to print-args:.*--flag1.*--flag2=value.*positional"; then 266 | error "Multiple arguments not passed correctly" 267 | error "Expected arguments: --flag1 --flag2=value positional" 268 | error "Got: $output" 269 | exit 1 270 | fi 271 | 272 | # Test passing arguments to a uv command 273 | log "Testing argument passing to uv command..." 274 | dela allow-command uv-run-arg --allow 2 || (error "Failed to allow uv-run-arg" && exit 1) 275 | 276 | output=$(dr uv-run-arg --flag1 --flag2=value) 277 | if ! echo "$output" | grep -q "Arguments:.*--flag1.*--flag2=value"; then 278 | error "Arguments not passed correctly to uv command" 279 | error "Expected to see arguments --flag1 --flag2=value in the output" 280 | error "Got: $output" 281 | exit 1 282 | fi 283 | 284 | log "=== All tests passed successfully! ===" -------------------------------------------------------------------------------- /tests/docker_zsh/zshrc.test: -------------------------------------------------------------------------------- 1 | # Basic zsh configuration for testing 2 | HISTFILE=~/.zsh_history 3 | HISTSIZE=1000 4 | SAVEHIST=1000 5 | 6 | # Basic zsh options 7 | setopt autocd 8 | setopt extendedglob 9 | setopt nomatch 10 | setopt notify 11 | 12 | # Basic prompt 13 | PS1='%n@%m:%~%# ' 14 | 15 | # Basic path 16 | path=(/usr/local/bin /usr/bin /bin $path) 17 | export PATH 18 | 19 | # Basic command not found handler (will be replaced by dela) 20 | function command_not_found_handler() { 21 | echo "Command not found: $1" >&2 22 | return 127 23 | } -------------------------------------------------------------------------------- /tests/run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit on any error 4 | set -e 5 | 6 | # Default to non-verbose output 7 | VERBOSE=${VERBOSE:-0} 8 | # Default platform to arm64 (can be overridden by CI) 9 | DOCKER_PLATFORM=${DOCKER_PLATFORM:-linux/arm64} 10 | # Default to empty builder image (will be set by CI if needed) 11 | BUILDER_IMAGE=${BUILDER_IMAGE:-} 12 | 13 | # Set up logging functions 14 | log() { 15 | if [ "$VERBOSE" = "1" ]; then 16 | echo "$@" 17 | fi 18 | } 19 | 20 | error() { 21 | echo "Error: $@" >&2 22 | } 23 | 24 | # Print usage information 25 | usage() { 26 | echo "Usage: $0 [shell]" 27 | echo " shell: Optional. One of: zsh, bash, fish, pwsh, noinit" 28 | echo " If no shell is specified, tests all shells" 29 | echo "" 30 | echo "Environment variables:" 31 | echo " VERBOSE=1: Enable verbose output" 32 | echo " DOCKER_PLATFORM: Platform for Docker builds (default: linux/arm64)" 33 | echo " BUILDER_IMAGE: Full path to builder image (default: uses local dela-builder)" 34 | exit 1 35 | } 36 | 37 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 38 | PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" 39 | log "SCRIPT_DIR: ${SCRIPT_DIR}" 40 | log "PROJECT_ROOT: ${PROJECT_ROOT}" 41 | 42 | # Function to run tests for a specific shell 43 | run_shell_tests() { 44 | local shell=$1 45 | local image_name="dela-test-${shell}" 46 | local dockerfile="Dockerfile" 47 | local test_script="test_${shell}.sh" 48 | local container_script="test_script.sh" 49 | 50 | # PowerShell uses .ps1 extension 51 | if [ "$shell" = "pwsh" ]; then 52 | test_script="test_${shell}.ps1" 53 | container_script="test_script.ps1" 54 | fi 55 | 56 | # If we have a builder image, update the Dockerfile 57 | if [ -n "$BUILDER_IMAGE" ]; then 58 | log "Using builder image: $BUILDER_IMAGE" 59 | sed -i.bak "s|FROM dela-builder|FROM ${BUILDER_IMAGE}|" "${SCRIPT_DIR}/docker_${shell}/${dockerfile}" 60 | fi 61 | 62 | # Build the Docker image 63 | log "Building ${shell} test image..." 64 | if [ "$VERBOSE" = "1" ]; then 65 | docker build \ 66 | --platform "$DOCKER_PLATFORM" \ 67 | -t "${image_name}" \ 68 | -f "${SCRIPT_DIR}/docker_${shell}/${dockerfile}" \ 69 | "${PROJECT_ROOT}" 70 | else 71 | docker build \ 72 | --platform "$DOCKER_PLATFORM" \ 73 | -t "${image_name}" \ 74 | -f "${SCRIPT_DIR}/docker_${shell}/${dockerfile}" \ 75 | "${PROJECT_ROOT}" >/dev/null 2>&1 76 | fi 77 | 78 | # Restore the original Dockerfile if we modified it 79 | if [ -n "$BUILDER_IMAGE" ]; then 80 | mv "${SCRIPT_DIR}/docker_${shell}/${dockerfile}.bak" "${SCRIPT_DIR}/docker_${shell}/${dockerfile}" 81 | fi 82 | 83 | # Run the tests 84 | log "Running ${shell} tests..." 85 | if [ "$VERBOSE" = "1" ]; then 86 | docker run --rm \ 87 | --platform "$DOCKER_PLATFORM" \ 88 | -v "${SCRIPT_DIR}/docker_${shell}/${test_script}:/home/testuser/${container_script}:ro" \ 89 | -e VERBOSE=1 \ 90 | "${image_name}" 91 | else 92 | # Run tests in non-verbose mode and capture output 93 | output=$(docker run --rm \ 94 | --platform "$DOCKER_PLATFORM" \ 95 | -v "${SCRIPT_DIR}/docker_${shell}/${test_script}:/home/testuser/${container_script}:ro" \ 96 | -e VERBOSE=0 \ 97 | "${image_name}" 2>&1) || { 98 | echo "Test failed. Output:" 99 | echo "$output" 100 | return 1 101 | } 102 | fi 103 | 104 | echo "${shell} tests passed successfully!" 105 | } 106 | 107 | # Check if a specific shell was requested 108 | if [ $# -eq 1 ]; then 109 | shell=$1 110 | # Validate shell argument 111 | case $shell in 112 | zsh|bash|fish|pwsh|noinit|unit) 113 | log "Testing ${shell} shell integration..." 114 | run_shell_tests "${shell}" 115 | ;; 116 | *) 117 | error "Invalid shell: ${shell}" 118 | usage 119 | ;; 120 | esac 121 | else 122 | # Test all shells 123 | for shell in unit noinit zsh bash fish pwsh; do 124 | log "Testing ${shell} shell integration..." 125 | run_shell_tests "${shell}" 126 | done 127 | fi -------------------------------------------------------------------------------- /tests/task_definitions/Makefile: -------------------------------------------------------------------------------- 1 | # Test Makefile for dela Docker tests 2 | 3 | .PHONY: test-task another-task help cd custom-exe print-args test 4 | 5 | test-task: ## Test task for basic functionality 6 | @echo "Test task executed successfully" 7 | 8 | test: ## Test task that will conflict with other test tasks 9 | @echo "Make test task executed successfully" 10 | 11 | another-task: ## Another test task 12 | @echo "Another task executed successfully" 13 | 14 | print-arg-task: ## Test task for basic functionality with args 15 | @echo "Argument is: $(ARG)" 16 | 17 | print-args: ## Echo all arguments passed to the task 18 | @echo "Arguments passed to print-args: $(subst ',,$(ARGS))" 19 | 20 | cd: ## Task that will be shadowed by shell builtin 21 | @echo "This task is shadowed by the cd shell builtin" 22 | 23 | custom-exe: ## Task that will be shadowed by PATH executable 24 | @echo "This task is shadowed by a PATH executable" 25 | 26 | help: ## Show this help message 27 | @echo "Available tasks:" 28 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' -------------------------------------------------------------------------------- /tests/task_definitions/Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | tasks: 4 | task-test: 5 | desc: Test task for basic functionality 6 | cmds: 7 | - echo "Test task executed successfully" 8 | 9 | task-build: 10 | desc: Build task for basic functionality 11 | cmds: 12 | - echo "Build task executed successfully" 13 | 14 | task-deps: 15 | desc: Task with dependencies 16 | deps: 17 | - task-test 18 | cmds: 19 | - echo "Dependency task executed successfully" -------------------------------------------------------------------------------- /tests/task_definitions/assets_py/__init__.py: -------------------------------------------------------------------------------- 1 | # Assets Python package -------------------------------------------------------------------------------- /tests/task_definitions/assets_py/main.py: -------------------------------------------------------------------------------- 1 | def main_test(): 2 | print("Test task executed successfully") 3 | 4 | def main_build(): 5 | print("Build task executed successfully") 6 | 7 | def run_arg_test(): 8 | import sys 9 | print(f"Arguments: {sys.argv}") 10 | print("Run arg test task executed successfully") 11 | 12 | if __name__ == "__main__": 13 | main() 14 | -------------------------------------------------------------------------------- /tests/task_definitions/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'java' 2 | apply plugin: 'application' 3 | 4 | repositories { 5 | mavenCentral() 6 | } 7 | 8 | dependencies { 9 | implementation 'org.slf4j:slf4j-api:1.7.30' 10 | testImplementation 'junit:junit:4.13.2' 11 | } 12 | 13 | task gradleTest { 14 | description 'A test task for Gradle' 15 | doLast { 16 | println 'Running Gradle test task' 17 | } 18 | } 19 | 20 | task gradleBuild { 21 | description 'A build task for Gradle' 22 | doLast { 23 | println 'Building with Gradle' 24 | } 25 | } 26 | 27 | application { 28 | mainClass = 'com.example.Main' 29 | } -------------------------------------------------------------------------------- /tests/task_definitions/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("java") 3 | id("application") 4 | } 5 | 6 | repositories { 7 | mavenCentral() 8 | } 9 | 10 | dependencies { 11 | implementation("org.slf4j:slf4j-api:1.7.30") 12 | testImplementation("junit:junit:4.13.2") 13 | } 14 | 15 | tasks.register("kotlinGradleTest") { 16 | description = "A test task for Kotlin Gradle DSL" 17 | doLast { 18 | println("Running Kotlin Gradle test task") 19 | } 20 | } 21 | 22 | tasks.register("kotlinGradleBuild") { 23 | description = "A build task for Kotlin Gradle DSL" 24 | doLast { 25 | println("Building with Kotlin Gradle") 26 | } 27 | } 28 | 29 | application { 30 | mainClass.set("com.example.Main") 31 | } -------------------------------------------------------------------------------- /tests/task_definitions/github_actions/.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test Workflow 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | name: Build Project 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Build 16 | run: echo "Building the project" 17 | 18 | test: 19 | name: Run Tests 20 | needs: build 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Test 25 | run: echo "Running tests" 26 | 27 | deploy: 28 | name: Deploy to Production 29 | needs: [build, test] 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v3 33 | - name: Deploy 34 | run: echo "Deploying to production" -------------------------------------------------------------------------------- /tests/task_definitions/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dela-test", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "dela-test", 9 | "version": "1.0.0" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/task_definitions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dela-test", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "npm-test": "echo \"NPM test task executed successfully\"", 6 | "npm-build": "echo \"NPM build task executed successfully\"", 7 | "test": "echo \"NPM test task executed successfully\"" 8 | } 9 | } -------------------------------------------------------------------------------- /tests/task_definitions/poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. 2 | package = [] 3 | 4 | [metadata] 5 | lock-version = "2.1" 6 | python-versions = ">=3.12" 7 | content-hash = "80b608f86ef4a24e61668435f37d1a50f6069d7f163943da1c93160fe03989cb" 8 | -------------------------------------------------------------------------------- /tests/task_definitions/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | com.example 8 | dela-test 9 | 1.0-SNAPSHOT 10 | 11 | 12 | 11 13 | 11 14 | 15 | 16 | 17 | 18 | 19 | org.apache.maven.plugins 20 | maven-compiler-plugin 21 | 3.8.1 22 | 23 | 24 | compile-java 25 | 26 | compile 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | dev 37 | 38 | development 39 | 40 | 41 | 42 | prod 43 | 44 | production 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /tests/task_definitions/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "assets_py" 3 | version = "1.0.0" 4 | requires-python = ">=3.12" 5 | description = "Example project to demonstrate entry points" 6 | dependencies = [] 7 | 8 | [project.scripts] 9 | uv-build = "assets_py.main:main_build" 10 | uv-test = "assets_py.main:main_test" 11 | uv-run-arg = "assets_py.main:run_arg_test" 12 | test = "assets_py.main:main_test" 13 | 14 | [tool.poetry] 15 | name = "assets_py" 16 | version = "0.1.0" 17 | description = "Example project to demonstrate Poetry scripts" 18 | authors = ["Alex"] 19 | package-mode = false 20 | 21 | 22 | [tool.poetry.dependencies] 23 | python = ">=3.12" 24 | 25 | [tool.poetry.scripts] 26 | poetry-build = "assets_py.main:main_build" 27 | poetry-test = "assets_py.main:main_test" 28 | test = "assets_py.main:main_test" 29 | 30 | 31 | [build-system] 32 | requires = ["setuptools>=42", "wheel"] 33 | build-backend = "setuptools.build_meta" 34 | -------------------------------------------------------------------------------- /tests/task_definitions/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="assets_py", 5 | version="1.0.0", 6 | packages=["assets_py"], 7 | entry_points={ 8 | "console_scripts": [ 9 | "uv-build=assets_py.main:main_build", 10 | "uv-test=assets_py.main:main_test", 11 | ], 12 | }, 13 | python_requires=">=3.12", 14 | ) 15 | -------------------------------------------------------------------------------- /tests/task_definitions/uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.12" 3 | 4 | [[package]] 5 | name = "assets-py" 6 | version = "1.0.0" 7 | source = { editable = "." } 8 | --------------------------------------------------------------------------------