diff --git a/.bwd-root b/.bwd-root new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.bwd-root @@ -0,0 +1 @@ + diff --git a/Cargo.lock b/Cargo.lock index 5f6537a..0d311e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -518,7 +518,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "pavidi" -version = "0.1.0-preview.2" +version = "0.1.0" dependencies = [ "anyhow", "blake3", diff --git a/Cargo.toml b/Cargo.toml index 9505d3d..3f0bbfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pavidi" -version = "0.1.0-preview.2" +version = "0.1.0" description = "A task runner." license = "Apache-2.0" readme = "README.md" diff --git a/README.md b/README.md index 181728b..2ee17f1 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,184 @@ -# Pavidi (P) +# Pavidi (P) - The Minimalist, Powerful Task Runner -> **PREVIEW STAGE** -> This software is currently in preview. Features and APIs are subject to change. Use with caution. +**Pavidi** (executable as `p`) is a modern, cross-platform task runner built in Rust. It aims to provide a consistent execution layer across different operating systems, handling dependencies, environment variables, and parallel execution with ease. -Pavidi (or simply **P**) is a powerful task runner and shell environment built in Rust. It aims to provide a consistent execution layer across different operating systems. +**P stands for:** -## Components +* **Pavidi**: The identity of a minimalist, powerful runner. +* **Perfect**: Smart caching, parallel execution, and rock-solid task management. +* **Portable**: Cross-platform by design with built-in commands that work everywhere. -- **Pavidi (Core):** The project-aware task runner that manages configuration, dependencies, and execution flow. +[**๐Ÿ“š Read the Full Documentation**](docs/index.md) -## Installation +## ๐Ÿš€ Key Features + +- **Cross-Platform Compatibility**: Write tasks once, run anywhere (Windows, Linux, macOS). +- **Portable Commands**: Built-in cross-platform commands like `p:rm`, `p:cp`, `p:mkdir` ensuring your scripts work on any OS. +- **Dependency Management**: Define task dependencies and execute them sequentially or in parallel. +- **Smart Caching**: Skip tasks if inputs haven't changed (hashing based on source files and environment variables). +- **Environment Management**: First-class support for `.env` files, dynamic variable resolution, and rigorous environment provenance tracking. +- **Secret Redaction**: Automatically mask sensitive information in logs. +- **Configuration Hierarchy**: Modular configuration with `p.toml` and extension files (`p.*.toml`). +- **Parallel Execution**: Leverage multi-core processors for independent tasks. + +## ๐Ÿ“ฆ Installation To build and install from source: -```sh +```bash cargo install --path . ``` -## Configuration (`p.toml`) +Ensure `~/.cargo/bin` is in your `PATH`. + +## โšก Quick Start -Project configuration is defined in a `p.toml` file at the root of your project. +Create a `p.toml` file in your project root: ```toml [project] -name = "my-awesome-app" +name = "my-awesome-project" version = "0.1.0" [env] RUST_LOG = "info" -app_port = "8080" +PORT = "8080" -# Simple task definition -[runner] -clean = "rm -rf target/" -format = ["cargo fmt", "cargo clippy"] - -# Full task definition with dependencies and metadata [runner.build] cmds = ["cargo build --release"] -description = "Builds the project for release" +description = "Build the project" [runner.test] cmds = ["cargo test"] deps = ["build"] ignore_failure = false + +[runner.clean] +cmds = ["p:rm -rf target/"] +description = "Clean build artifacts" +``` + +Run a task: +```bash +p build +``` + +## ๐Ÿ“– Configuration Reference (`p.toml`) + +Pavidi uses `p.toml` as its primary configuration file. + +### Project Metadata + +Define project-wide settings under `[project]`. + +```toml +[project] +name = "my-project" +version = "1.0.0" +authors = ["Alice "] +description = "A sample project" +shell = "bash" # Optional: Force a specific shell (defaults to system default) +log_strategy = "always" # Options: "always", "error-only", "none" +log_plain = false # Disable colored logs if true +secret_patterns = ["API_KEY_.*"] # Regex patterns to redact in logs +``` + +### Environment Variables (`[env]`) + +Define environment variables that are available to all tasks. + +```toml +[env] +DATABASE_URL = "postgres://localhost:5432/mydb" +API_KEY = "secret-123" +# Dynamic variables (executed at runtime) +GIT_HASH = "$(git rev-parse --short HEAD)" +``` + +- **.env Files**: Pavidi automatically loads `.env` files. If `P_ENV` is set (e.g., `P_ENV=prod`), it looks for `.env.prod`. +- **Precedence**: `.env` files override `p.toml` variables. + +### Task Definitions (`[runner]`) + +Tasks are defined in the `[runner]` section. + +#### Simple Command +```toml +[runner] +lint = "cargo clippy" +format = ["cargo fmt", "prettier --write ."] +``` + +#### Full Task Configuration +For more control, use a table: + +```toml +[runner.deploy] +cmds = ["./deploy.sh"] +deps = ["build", "test"] # Tasks to run before this one +parallel = false # Run dependencies in parallel? (default: false) +description = "Deploy the application" + +# Conditional Execution +run_if = "test -f dist/app.bin" # Run only if command succeeds (exit code 0) +skip_if = "git diff --quiet" # Skip if command succeeds + +# Smart Caching (Skip if inputs/outputs are up-to-date) +sources = ["src/**/*.rs", "Cargo.toml"] +outputs = ["target/release/app"] + +# OS-Specific Overrides +windows = ["powershell ./deploy.ps1"] +linux = ["./deploy.sh"] +macos = ["./deploy.sh"] + +# Error Handling +ignore_failure = false # Fail if command fails? (default: false) +retry = 3 # Number of retries +retry_delay = 5 # Seconds between retries +timeout = 600 # Timeout in seconds + +# Cleanup +finally = ["p:rm tmp_file"] # Always runs after task (even on failure) +``` + +## ๐Ÿ›  Portable Commands + +Pavidi includes built-in commands to ensure cross-platform compatibility without relying on system shells. + +- `p:rm [files/dirs...]`: Remove files or directories (supports `-r` for recursive, `-f` for force). +- `p:cp [src] [dest]`: Copy files or directories (supports `-r` for recursive). +- `p:mkdir [dirs...]`: Create directories (supports `-p` implicitly). +- `p:ls [dirs...]`: List files. +- `p:mv [src] [dest]`: Move/Rename files. +- `p:cat [files...]`: Concatenate and print files. + +Example: +```toml +clean = "p:rm -rf target/ dist/" +``` + +## ๐Ÿ’ป CLI Usage + +```bash +p [TASK] [ARGS...] ``` -## Usage +- **Run a task**: `p build` +- **Pass arguments**: `p run -- --port 9000` (arguments after `--` are passed to the task) +- **List tasks**: `p -l` or `p --list` +- **Show Info**: `p -i` or `p --info` (shows loaded config and extensions) +- **Inspect Env**: `p --env` (shows resolved environment variables) +- **Trace Env**: `p -e --trace` (shows where each variable came from) +- **Dry Run**: `p --dry-run` (print commands without executing) -P uses short, mnemonic commands for efficiency. +## ๐Ÿงฉ Advanced Features -- **Run a task:** - ```sh - p - # Example: p build - ``` +### Configuration Extensions +You can split configuration into multiple files using the naming convention `p.*.toml`. These are loaded alphabetically and merged into the main configuration. This is useful for: +- User-specific overrides (`p.local.toml` - typically gitignored). +- Modular configurations for large projects. -- **List available tasks:** - ```sh - p -l - ``` +### Smart Caching +Pavidi computes a BLAKE3 hash of the files matched by `sources` and the environment variables. It compares this against a stored hash. If the hash matches AND the files in `outputs` exist, the task is skipped. -- **Show project info:** - ```sh - p -i - ``` diff --git a/context7.json b/context7.json new file mode 100644 index 0000000..bf93629 --- /dev/null +++ b/context7.json @@ -0,0 +1,4 @@ +{ + "url": "https://context7.com/codetease/p", + "public_key": "pk_pimPBrw2BH0L1lQC5NWL7" +} diff --git a/dist-workspace.toml b/dist-workspace.toml index 7884655..acc93b7 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -8,9 +8,9 @@ cargo-dist-version = "0.30.3" # CI backends to support ci = "github" # The installers to generate for each app -installers = ["shell", "powershell", "homebrew", "msi"] +installers = ["shell", "powershell", "homebrew"] # Target platforms to build apps for (Rust target-triple syntax) -targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"] +targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-pc-windows-msvc", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"] # Path that installers should place binaries in install-path = "CARGO_HOME" # Whether to install an updater program diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..ffabb51 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,11 @@ +# Documentation + +This directory contains the documentation for **Pavidi**. + +* [**Introduction**](index.md) - Start here. +* [**Getting Started**](getting-started.md) - Installation and your first task. +* [**Configuration**](configuration.md) - `p.toml` structure, environment variables, and more. +* [**Task Runner**](task-runner.md) - Defining tasks, dependencies, parallel execution. +* [**Portable Commands**](portable-commands.md) - Cross-platform commands like `p:rm`, `p:cp`. +* [**Smart Caching**](smart-caching.md) - Optimizing performance with caching. +* [**Advanced**](advanced.md) - Extensions, logging, debugging, and secrets. diff --git a/docs/advanced.md b/docs/advanced.md new file mode 100644 index 0000000..05b27de --- /dev/null +++ b/docs/advanced.md @@ -0,0 +1,68 @@ +# Advanced Features + +Unlock the full potential of Pavidi with these advanced configuration and debugging features. + +## Modular Configuration (Extensions) + +For large projects or user-specific overrides, you can split your configuration into multiple files. Pavidi automatically loads files matching the pattern `p.*.toml` in the project root. + +### Example Workflow + +1. **`p.toml` (Base Config):** Contains the shared project configuration. +2. **`p.local.toml` (User Overrides):** Contains developer-specific settings (e.g., local database URL). Add this file to `.gitignore`. +3. **`p.ci.toml` (CI/CD Config):** Contains settings specific to the CI environment. + +### Merging Rules + +* **Extensions are loaded alphabetically.** +* **Deep Merge:** `[env]` and `[runner]` sections are merged. +* **Overrides:** Values in later files override those in earlier files. + +## Logging & Debugging + +When things go wrong, Pavidi provides tools to help you understand what's happening. + +### Trace Mode (`--trace`) + +Use the `--trace` flag to see detailed execution logs, including environment variable resolution history. + +```bash +p build --trace +``` + +### Environment Inspection (`--env`) + +To see the final resolved environment variables available to tasks: + +```bash +p --env +``` + +To see *where* each variable came from (e.g., system, `p.toml`, `.env`), combine with `--trace`: + +```bash +p -e --trace +``` + +### Dry Run (`--dry-run`) + +Preview the commands that would be executed without actually running them: + +```bash +p build --dry-run +``` + +## Secret Redaction + +Pavidi automatically attempts to redact sensitive information from logs. You can configure custom patterns in `p.toml`. + +```toml +[project] +secret_patterns = ["API_KEY_.*", "PASSWORD_.*"] +``` + +Any output matching these regex patterns will be replaced with `[REDACTED]` in the console and log files. + +--- + +[**Back to Introduction**](index.md) diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..b7ecf20 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,67 @@ +# Configuration + +Pavidi uses `p.toml` as its primary configuration file. This file sits at the root of your project and defines metadata, environment variables, and tasks. + +## `p.toml` Structure + +A typical `p.toml` file consists of three main sections: `[project]`, `[env]`, and `[runner]`. + +```toml +[project] +name = "my-project" +version = "1.0.0" +shell = "bash" # Optional: Force a specific shell (defaults to system default) + +[env] +PORT = "8080" +APP_ENV = "dev" + +[runner] +# Task definitions go here +``` + +### Project Metadata (`[project]`) + +* `name`: The name of your project. +* `version`: The current version of your project. +* `authors`: (Optional) List of authors. +* `description`: (Optional) Description of the project. +* `shell`: (Optional) Override the default shell used to execute commands. + * Defaults: `sh` on Unix, `pwsh` or `cmd` on Windows. +* `log_strategy`: (Optional) Control logging verbosity ("always", "error-only", "none"). +* `log_plain`: (Optional) Set to `true` to disable colored output. +* `secret_patterns`: (Optional) List of regex patterns to redact from logs. + +### Environment Variables (`[env]`) + +You can define environment variables that will be available to all tasks executed by Pavidi. + +```toml +[env] +DATABASE_URL = "postgres://localhost:5432/mydb" +API_KEY = "secret-123" +``` + +#### Dynamic Variables + +Pavidi supports dynamic variable resolution using the `$(command)` syntax. The command inside the parentheses is executed, and its standard output is captured as the variable value. + +```toml +[env] +# Capture the current git commit hash +GIT_HASH = "$(git rev-parse --short HEAD)" +# Capture the current date +BUILD_DATE = "$(date +%Y-%m-%d)" +``` + +### `.env` File Integration + +Pavidi has first-class support for `.env` files. + +1. **Automatic Loading:** If a `.env` file exists in the directory where `p` is run, it is automatically loaded. +2. **Precedence:** Variables defined in `.env` files **override** those defined in `p.toml`. +3. **Environment Switching:** If the `P_ENV` environment variable is set (e.g., `P_ENV=prod`), Pavidi will attempt to load `.env.prod` instead of `.env`. + +--- + +[**Next step: Task Runner**](task-runner.md) diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..141153a --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,87 @@ +# Getting Started with Pavidi + +## Installation + +Pavidi is designed to be easy to install on any platform. Choose the method that works best for you. + +### Option 1: Automated Installer (Recommended) + +You can use the automated installer script provided by `cargo-dist`. + +**Linux & macOS:** + +```bash +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/CodeTease/p/releases/latest/download/pavidi-installer.sh | sh +``` + +**Windows (PowerShell):** + +```powershell +irm https://github.com/CodeTease/p/releases/latest/download/pavidi-installer.ps1 | iex +``` + +### Option 2: Homebrew (macOS & Linux) + +If you use Homebrew, you can install Pavidi from our custom tap: + +```bash +brew install CodeTease/tap/pavidi +``` + +### Option 3: Build from Source (Cargo) + +If you have Rust installed, you can build Pavidi from source: + +```bash +# Install from git +cargo install --git https://github.com/CodeTease/p + +# Or if you have cloned the repository locally: +cargo install --path . +``` + +Ensure that `~/.cargo/bin` is in your system's `PATH`. + +--- + +## Hello World + +Let's create your first task. Pavidi uses a file named `p.toml` in your project root to define configuration and tasks. + +1. **Create a `p.toml` file:** + + ```toml + [project] + name = "hello-pavidi" + version = "0.1.0" + + [runner.hello] + cmds = ["echo 'Hello, Pavidi!'"] + description = "Prints a greeting" + ``` + +2. **Run the task:** + + Open your terminal and run: + + ```bash + p hello + ``` + + You should see output similar to: + + ```text + Hello, Pavidi! + ``` + +3. **List available tasks:** + + Run `p --list` (or `p -l`) to see all tasks defined in your project: + + ```bash + p --list + ``` + +--- + +[**Next step: Configuration**](configuration.md) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..e6fc7a6 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,37 @@ +# Introduction to Pavidi + +**Pavidi** (executable as `p`) is a modern, minimalist, cross-platform task runner built in Rust. It is designed to provide a consistent execution layer across different operating systems, handling dependencies, environment variables, and parallel execution with ease. + +## Philosophy: Write Once, Run Anywhere + +The core philosophy of Pavidi is to eliminate the friction of maintaining different scripts for Windows, Linux, and macOS. By using Pavidi's configuration and portable commands, you can define your build, test, and deployment pipelines once, and they will work seamlessly on any developer's machine or CI/CD environment. + +## Table of Contents + +1. [**Getting Started**](getting-started.md) + * Installation (Script, Homebrew, Cargo) + * Hello World +2. [**Configuration**](configuration.md) + * `p.toml` Structure + * Environment Variables & `.env` Files + * Dynamic Variables +3. [**Task Runner**](task-runner.md) + * Task Definitions + * Dependencies & Parallel Execution + * Conditional Logic (`run_if`, `skip_if`) + * OS-Specific Overrides +4. [**Portable Commands**](portable-commands.md) + * `p:rm`, `p:cp`, `p:mkdir`, etc. + * Why use them? +5. [**Smart Caching**](smart-caching.md) + * Hashing Mechanism + * `sources` and `outputs` + * CI/CD Benefits +6. [**Advanced Topics**](advanced.md) + * Modular Configuration (Extensions) + * Logging & Debugging + * Secret Redaction + +--- + +[**Next step: Getting Started**](getting-started.md) diff --git a/docs/portable-commands.md b/docs/portable-commands.md new file mode 100644 index 0000000..11470fa --- /dev/null +++ b/docs/portable-commands.md @@ -0,0 +1,77 @@ +# Portable Commands + +One of the biggest challenges in cross-platform development is inconsistent shell commands. Pavidi solves this with **Portable Commands**โ€”built-in utilities that work identically on Windows, Linux, and macOS. + +These commands are prefixed with `p:` and do not require external tools like Git Bash or Coreutils on Windows. + +## Available Commands + +### `p:rm` (Remove) +Removes files or directories. + +* **Syntax:** `p:rm [flags] ` +* **Flags:** + * `-r`, `-R`, `--recursive`: Recursively remove directories. + * `-f`, `--force`: Ignore nonexistent files and arguments. + +```toml +clean = "p:rm -rf target/ dist/" +``` + +### `p:cp` (Copy) +Copies files or directories. + +* **Syntax:** `p:cp [flags] ` +* **Flags:** + * `-r`, `-R`, `--recursive`: Recursively copy directories. + +```toml +backup = "p:cp -r src/ src_backup/" +``` + +### `p:mkdir` (Make Directory) +Creates directories. + +* **Syntax:** `p:mkdir ` +* **Behavior:** Implicitly creates parent directories (like `mkdir -p`). + +```toml +setup = "p:mkdir -p build/logs" +``` + +### `p:mv` (Move/Rename) +Moves or renames files/directories. + +* **Syntax:** `p:mv ` + +```toml +rename = "p:mv output.txt final_output.txt" +``` + +### `p:ls` (List) +Lists directory contents. + +* **Syntax:** `p:ls ` + +```toml +check = "p:ls dist/" +``` + +### `p:cat` (Concatenate) +Reads files and prints to standard output. + +* **Syntax:** `p:cat ` + +```toml +show_config = "p:cat p.toml" +``` + +## Why Use Portable Commands? + +1. **Consistency:** No more `rm -rf` failing on Windows Command Prompt or `del` failing on Linux. +2. **No Dependencies:** Users don't need to install extra tools. +3. **Speed:** Built directly into Pavidi (Rust), they are faster than spawning external shell processes. + +--- + +[**Next step: Smart Caching**](smart-caching.md) diff --git a/docs/smart-caching.md b/docs/smart-caching.md new file mode 100644 index 0000000..afd5cfd --- /dev/null +++ b/docs/smart-caching.md @@ -0,0 +1,52 @@ +# Smart Caching + +Pavidi includes a built-in caching mechanism to speed up your builds and tests. If a task's inputs and outputs haven't changed, Pavidi can skip execution. + +## How it Works + +When you define `sources` and `outputs` for a task, Pavidi: + +1. **Calculates a Hash:** + * Computes a cryptographic hash (BLAKE3) of the contents of all files matching the `sources` patterns. + * Includes the values of all environment variables defined in `[env]`. + * Includes the command string itself. + +2. **Checks Consistency:** + * Verifies if all files specified in `outputs` exist on disk. + +3. **Compares:** + * If the calculated hash matches the stored hash from the previous run **AND** all output files exist, the task is considered "up-to-date". + * Pavidi skips execution and logs that the task was cached. + +## Configuration + +To enable caching for a task, define `sources` and `outputs` in your `p.toml`. + +```toml +[runner.build] +cmds = ["cargo build --release"] +# Files to watch for changes +sources = ["src/**/*.rs", "Cargo.toml", "Cargo.lock"] +# Files created by the command +outputs = ["target/release/pavidi"] +``` + +### Glob Patterns + +Pavidi supports standard glob patterns for `sources`: + +* `*`: Matches any sequence of characters (except path separators). +* `**`: Matches directories recursively. +* `?`: Matches any single character. + +## Benefits for CI/CD + +Smart caching is particularly powerful in Continuous Integration (CI) environments. + +1. **Faster Builds:** Avoid rebuilding artifacts that haven't changed. +2. **Resource Efficiency:** Save CPU cycles and reduce billable minutes on CI providers. +3. **Consistency:** Ensure that tasks run only when necessary, reducing the chance of flaky tests due to stale artifacts. + +--- + +[**Next step: Advanced Features**](advanced.md) diff --git a/docs/task-runner.md b/docs/task-runner.md new file mode 100644 index 0000000..59bfd7f --- /dev/null +++ b/docs/task-runner.md @@ -0,0 +1,109 @@ +# Task Runner + +The core of Pavidi is its task runner, which allows you to define and execute complex workflows. + +## Defining Tasks + +Tasks are defined in the `[runner]` section of `p.toml`. + +### Simple Commands + +For simple tasks that only run a single command, you can use a string or a list of strings: + +```toml +[runner] +clean = "rm -rf target/" +format = ["cargo fmt", "prettier --write ."] +``` + +### Advanced Task Configuration + +For more complex scenarios, use a table to define properties like dependencies, conditions, and timeouts. + +```toml +[runner.deploy] +cmds = ["./deploy.sh"] +description = "Deploy to production" +timeout = 600 # Timeout in seconds +``` + +## Dependencies & Parallel Execution + +Tasks can depend on other tasks. Pavidi ensures that dependencies run *before* the main task. + +```toml +[runner.test] +cmds = ["cargo test"] +deps = ["build"] # Run 'build' before 'test' +``` + +### Parallel Execution + +By default, dependencies run sequentially. You can enable parallel execution to speed up your workflow. + +```toml +[runner.ci] +cmds = ["echo 'CI Finished'"] +deps = ["lint", "test", "audit"] +parallel = true # Run 'lint', 'test', and 'audit' simultaneously +``` + +## Conditional Logic + +Pavidi allows you to control *when* a task runs using `run_if` and `skip_if`. + +### `run_if` + +Executes the task **only if** the provided command succeeds (exit code 0). + +```toml +[runner.migrate] +cmds = ["./migrate_db.sh"] +run_if = "test -f db/migrations.sql" # Only migrate if migration file exists +``` + +### `skip_if` + +Skips the task **if** the provided command succeeds (exit code 0). + +```toml +[runner.setup] +cmds = ["./install_deps.sh"] +skip_if = "test -d node_modules" # Skip if node_modules already exists +``` + +### `ignore_failure` + +If a command fails, Pavidi usually stops execution. Set `ignore_failure = true` to continue anyway. + +```toml +[runner.flaky_task] +cmds = ["./sometimes_fails.sh"] +ignore_failure = true +``` + +## Cleanup (`finally`) + +The `finally` block specifies commands that run **after** the main commands, regardless of success or failure. This is useful for cleanup. + +```toml +[runner.integration_test] +cmds = ["./run_tests.sh"] +finally = ["./cleanup_db.sh"] # Always runs +``` + +## OS-Specific Overrides + +Pavidi lets you define different commands for Windows, Linux, and macOS. This is essential for true cross-platform compatibility. + +```toml +[runner.open_browser] +cmds = ["echo 'Opening browser...'"] +windows = ["start http://localhost:8080"] +linux = ["xdg-open http://localhost:8080"] +macos = ["open http://localhost:8080"] +``` + +--- + +[**Next step: Portable Commands**](portable-commands.md) diff --git a/src/cli.rs b/src/cli.rs index 4d1cb39..654b0a1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,6 +11,10 @@ pub struct Cli { #[arg(short, long)] pub env: bool, + /// Show detailed trace of variable overrides + #[arg(long)] + pub trace: bool, + /// Show project/module metadata #[arg(short = 'i', long = "info")] pub info: bool, diff --git a/src/config.rs b/src/config.rs index 11894a6..0744025 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,7 +3,7 @@ use colored::*; use serde::Deserialize; use std::collections::HashMap; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::env; use crate::runner::task::RunnerTask; use regex::Regex; @@ -17,6 +17,13 @@ pub struct PavidiConfig { #[serde(default)] pub env: HashMap, pub runner: Option>, + + #[serde(skip)] + pub env_provenance: HashMap>, + #[serde(skip)] + pub extensions_applied: Vec<(String, Metadata)>, + #[serde(skip)] + pub original_metadata: Option, } #[derive(Debug, Deserialize, Clone)] @@ -42,6 +49,7 @@ pub struct ProjectConfig { pub shell: Option, pub log_strategy: Option, pub log_plain: Option, + pub secret_patterns: Option>, } #[derive(Debug, Deserialize)] @@ -51,6 +59,7 @@ pub struct ModuleConfig { pub shell: Option, pub log_strategy: Option, pub log_plain: Option, + pub secret_patterns: Option>, } #[derive(Debug, Deserialize, Clone)] @@ -58,6 +67,60 @@ pub struct CapabilityConfig { pub allow_paths: Option>, } +fn merge_configurations(base: &mut PavidiConfig, extension: PavidiConfig) { + // Merge Env (Overwrite) + base.env.extend(extension.env); + + // Merge Runner Tasks (Overwrite) + if let Some(ext_runner) = extension.runner { + let base_runner = base.runner.get_or_insert_with(HashMap::new); + base_runner.extend(ext_runner); + } + + // Merge Capability (Allow Paths) - Append unique paths + if let Some(ext_cap) = extension.capability { + if let Some(ext_paths) = ext_cap.allow_paths { + let base_cap = base.capability.get_or_insert(CapabilityConfig { allow_paths: Some(vec![]) }); + let base_paths = base_cap.allow_paths.get_or_insert(vec![]); + for p in ext_paths { + if !base_paths.contains(&p) { + base_paths.push(p); + } + } + } + } + + // Merge Project Config (Settings only) + if let Some(ext_proj) = extension.project { + if let Some(base_proj) = &mut base.project { + if let Some(s) = ext_proj.shell { base_proj.shell = Some(s); } + if let Some(l) = ext_proj.log_strategy { base_proj.log_strategy = Some(l); } + if let Some(p) = ext_proj.log_plain { base_proj.log_plain = Some(p); } + + // Append secret patterns + if let Some(ext_patterns) = ext_proj.secret_patterns { + let base_patterns = base_proj.secret_patterns.get_or_insert(vec![]); + base_patterns.extend(ext_patterns); + } + } + } + + // Merge Module Config (Settings only) + if let Some(ext_mod) = extension.module { + if let Some(base_mod) = &mut base.module { + if let Some(s) = ext_mod.shell { base_mod.shell = Some(s); } + if let Some(l) = ext_mod.log_strategy { base_mod.log_strategy = Some(l); } + if let Some(p) = ext_mod.log_plain { base_mod.log_plain = Some(p); } + + // Append secret patterns + if let Some(ext_patterns) = ext_mod.secret_patterns { + let base_patterns = base_mod.secret_patterns.get_or_insert(vec![]); + base_patterns.extend(ext_patterns); + } + } + } +} + pub fn load_config(dir: &Path) -> Result { let config_path = dir.join("p.toml"); if !config_path.exists() { @@ -68,6 +131,21 @@ pub fn load_config(dir: &Path) -> Result { // 1. Parse p.toml (Base Layer) let mut config: PavidiConfig = toml::from_str(&content).context("Failed to parse p.toml")?; + // Initialize provenance tracking + config.env_provenance = HashMap::new(); + for (k, v) in &config.env { + config.env_provenance.insert(k.clone(), vec![("p.toml".to_string(), v.clone())]); + } + + // Capture original metadata + if let Some(p) = &config.project { + config.original_metadata = Some(p.metadata.clone()); + } else if let Some(m) = &config.module { + config.original_metadata = Some(m.metadata.clone()); + } + + config.extensions_applied = Vec::new(); + // Resolve relative paths in capabilities if let Some(caps) = &mut config.capability { if let Some(paths) = &mut caps.allow_paths { @@ -83,6 +161,58 @@ pub fn load_config(dir: &Path) -> Result { } } + // 1.5 Load Extensions (p.*.toml) + let pattern = dir.join("p.*.toml"); + let pattern_str = pattern.to_str().ok_or_else(|| anyhow::anyhow!("Invalid path pattern"))?; + + let mut extension_files: Vec = glob::glob(pattern_str)? + .filter_map(Result::ok) + .collect(); + + // Sort alphabetically to ensure deterministic order + extension_files.sort(); + + for ext_path in extension_files { + eprintln!("{} Loading extension config: {}", "โž•".blue(), ext_path.file_name().unwrap().to_string_lossy()); + let ext_content = fs::read_to_string(&ext_path).context("Failed to read extension config")?; + let mut ext_config: PavidiConfig = toml::from_str(&ext_content).context("Failed to parse extension config")?; + + let ext_name = ext_path.file_name().unwrap().to_string_lossy().to_string(); + + // Capture extension metadata + let meta = if let Some(p) = &ext_config.project { + p.metadata.clone() + } else if let Some(m) = &ext_config.module { + m.metadata.clone() + } else { + Metadata { name: None, version: None, authors: None, description: None } + }; + config.extensions_applied.push((ext_name.clone(), meta)); + + // Update provenance for vars in extension + for (k, v) in &ext_config.env { + config.env_provenance.entry(k.clone()).or_default().push((ext_name.clone(), v.clone())); + } + + // Resolve relative paths in extension capability BEFORE merging + if let Some(caps) = &mut ext_config.capability { + if let Some(paths) = &mut caps.allow_paths { + let resolved: Vec = paths.iter().map(|p| { + let path = Path::new(p); + if path.is_absolute() { + p.clone() + } else { + // Resolve relative to the directory + dir.join(p).to_string_lossy().into_owned() + } + }).collect(); + *paths = resolved; + } + } + + merge_configurations(&mut config, ext_config); + } + // Validation: Exclusive Project vs Module if config.project.is_some() && config.module.is_some() { bail!("โŒ Configuration Error: 'p.toml' cannot contain both [project] and [module] sections. Please use only one."); @@ -103,6 +233,10 @@ pub fn load_config(dir: &Path) -> Result { // This keeps the separation clean until execution. for item in dotenvy::from_path_iter(&env_path)? { let (key, val) = item?; + + // Track provenance + config.env_provenance.entry(key.clone()).or_default().push((env_filename.clone(), val.clone())); + // .env overrides p.toml config.env.insert(key, val); } @@ -138,6 +272,12 @@ pub fn load_config(dir: &Path) -> Result { } } } + + // Update provenance for dynamic vars + for (k, v) in &updates { + config.env_provenance.entry(k.clone()).or_default().push(("dynamic".to_string(), v.clone())); + } + config.env.extend(updates); Ok(config) diff --git a/src/handlers/env.rs b/src/handlers/env.rs index 7f65275..73dbcdd 100644 --- a/src/handlers/env.rs +++ b/src/handlers/env.rs @@ -1,22 +1,95 @@ use anyhow::Result; use colored::*; use std::env; +use std::collections::HashSet; use crate::config::load_config; +use crate::cli::Cli; -pub fn handle_env() -> Result<()> { +pub fn handle_env(cli: &Cli) -> Result<()> { let current_dir = env::current_dir()?; // Load config which merges p.toml and .env let config = load_config(¤t_dir)?; - println!("{} Merged Environment Variables:", "๐Ÿ”".cyan()); - - // Sort keys for better readability - let mut keys: Vec<&String> = config.env.keys().collect(); - keys.sort(); + if cli.trace { + println!("{} Environment Variable Trace:", "๐Ÿ”".cyan()); + + let mut keys: Vec<&String> = config.env_provenance.keys().collect(); + keys.sort(); - for key in keys { - if let Some(val) = config.env.get(key) { - println!("{} = {}", key.bold(), val); + for key in keys { + let history = &config.env_provenance[key]; + println!("{}:", key.bold()); + for (idx, (source, val)) in history.iter().enumerate() { + let prefix = if idx == history.len() - 1 { "โ””โ”€โ”€".green() } else { "โ”œโ”€โ”€".blue() }; + println!(" {} {} = {} ({})", prefix, source, val, if idx == history.len() - 1 { "active".green() } else { "overridden".red().dimmed() }); + } + } + } else { + println!("{} Environment Variables (Layered):", "๐Ÿ”".cyan()); + + // Identify all unique sources involved, preserving order if possible + let mut ordered_sources = Vec::new(); + let mut seen_sources = HashSet::new(); + + // 1. p.toml (always first if present) + if config.env_provenance.values().any(|h| h.iter().any(|(s, _)| s == "p.toml")) { + ordered_sources.push("p.toml".to_string()); + seen_sources.insert("p.toml".to_string()); + } + + // 2. Extensions (in applied order) + for (ext_name, _) in &config.extensions_applied { + if seen_sources.insert(ext_name.clone()) { + ordered_sources.push(ext_name.clone()); + } + } + + // 3. Other sources (.env, dynamic) found in provenance + // We'll collect them and append them. Typically .env is last. + // But we want to preserve some logical order. + let mut other_sources = Vec::new(); + for history in config.env_provenance.values() { + for (source, _) in history { + if !seen_sources.contains(source) { + if seen_sources.insert(source.clone()) { + other_sources.push(source.clone()); + } + } + } + } + // sort other sources alphabetically or just append? usually .env should be last. + other_sources.sort(); + ordered_sources.extend(other_sources); + + for source in ordered_sources { + println!("\n[{}]", source.yellow().bold()); + + // Find vars defined/modified in this source + let mut vars_in_source = Vec::new(); + + for (key, history) in &config.env_provenance { + // Find the index of this source in the history + if let Some(pos) = history.iter().position(|(s, _)| s == &source) { + let val = &history[pos].1; + let is_active = pos == history.len() - 1; + vars_in_source.push((key, val, is_active)); + } + } + + vars_in_source.sort_by_key(|k| k.0); + + if vars_in_source.is_empty() { + println!(" (none)"); + } + + for (key, val, is_active) in vars_in_source { + if is_active { + println!(" {} = {}", key.bold(), val); + } else { + // Show as overridden + println!(" {} = {} {}", key.dimmed().strikethrough(), val.dimmed().strikethrough(), "(overridden)".red().italic()); + } + } } } diff --git a/src/handlers/info.rs b/src/handlers/info.rs index ba552a1..8a276a6 100644 --- a/src/handlers/info.rs +++ b/src/handlers/info.rs @@ -18,23 +18,61 @@ pub fn handle_info() -> Result<()> { if let Some(meta) = metadata { println!("{}", "Project Information".bold().underline()); - if let Some(name) = &meta.name { - println!("{}: {}", "Name".cyan(), name); - } - if let Some(version) = &meta.version { - println!("{}: {}", "Version".cyan(), version); - } - if let Some(desc) = &meta.description { - println!("{}: {}", "Description".cyan(), desc); - } + let original = config.original_metadata.as_ref(); + + // Helper to print diff + let print_diff = |label: &str, current: &Option, orig_val_opt: Option<&String>| { + if let Some(curr_val) = current { + print!("{}: {}", label.cyan(), curr_val); + if let Some(orig_val) = orig_val_opt { + if curr_val != orig_val { + print!(" {} (was {})", "(modified)".yellow().italic(), orig_val.dimmed()); + } + } else if original.is_some() { + print!(" {} (new)", "(added)".green().italic()); + } + println!(""); + } + }; + + print_diff("Name", &meta.name, original.and_then(|m| m.name.as_ref())); + print_diff("Version", &meta.version, original.and_then(|m| m.version.as_ref())); + print_diff("Description", &meta.description, original.and_then(|m| m.description.as_ref())); + if let Some(authors) = &meta.authors { if !authors.is_empty() { - println!("{}: {}", "Authors".cyan(), authors.join(", ")); + print!("{}: {}", "Authors".cyan(), authors.join(", ")); + // Check if modified + let orig_authors = original.and_then(|m| m.authors.as_ref()); + if let Some(orig) = orig_authors { + if authors != orig { + print!(" {}", "(modified)".yellow().italic()); + } + } else if original.is_some() { + print!(" {}", "(new)".green().italic()); + } + println!(""); } } } else { println!("{}", "No project/module metadata found.".yellow()); } + println!("\n{}", "Extensions Applied".bold().underline()); + if !config.extensions_applied.is_empty() { + for (name, meta) in &config.extensions_applied { + print!("- {}", name.green()); + if let Some(ver) = &meta.version { + print!(" (v{})", ver); + } + if let Some(desc) = &meta.description { + print!(": {}", desc.dimmed()); + } + println!(""); + } + } else { + println!("{}", " (none)".dimmed()); + } + Ok(()) } diff --git a/src/handlers/task.rs b/src/handlers/task.rs index 3e83182..61cb695 100644 --- a/src/handlers/task.rs +++ b/src/handlers/task.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use crate::config::load_config; use crate::runner::{recursive_runner, CallStack}; -pub fn handle_runner_entry(task_name: String, extra_args: Vec, dry_run: bool) -> Result<()> { +pub fn handle_runner_entry(task_name: String, extra_args: Vec, dry_run: bool, trace: bool) -> Result<()> { let current_dir = env::current_dir()?; let config = load_config(¤t_dir)?; @@ -19,5 +19,5 @@ pub fn handle_runner_entry(task_name: String, extra_args: Vec, dry_run: let mut call_stack = CallStack::new(); // Root task is allowed to print directly to stdout/stderr (capture = false) - recursive_runner(&task_name, &config_arc, &mut call_stack, &extra_args, false, dry_run) + recursive_runner(&task_name, &config_arc, &mut call_stack, &extra_args, false, dry_run, trace, 0) } diff --git a/src/logger.rs b/src/logger.rs index 5082669..3abcf4e 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -113,6 +113,28 @@ pub fn write_log( file_content.push_str(&format!("End Time: {}\n", Local::now().to_rfc3339())); file_content.push_str("============================\n"); + // Apply Custom Secret Masking + let secret_patterns = if let Some(p) = &config.project { + p.secret_patterns.as_ref() + } else if let Some(m) = &config.module { + m.secret_patterns.as_ref() + } else { + None + }; + + if let Some(patterns) = secret_patterns { + for pattern in patterns { + match Regex::new(pattern) { + Ok(re) => { + file_content = re.replace_all(&file_content, "[REDACTED]").to_string(); + }, + Err(_) => { + // Ignore invalid regex patterns as per requirements + } + } + } + } + fs::write(&log_path, file_content).context("Failed to write log file")?; Ok(Some(log_path)) diff --git a/src/main.rs b/src/main.rs index 39e59f9..9bc7624 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,9 +19,9 @@ fn main() -> Result<()> { } else if cli.info { info::handle_info() } else if cli.env { - env::handle_env() + env::handle_env(&cli) } else { let task_name = cli.task.unwrap_or_else(|| "default".to_string()); - task::handle_runner_entry(task_name, cli.args, cli.dry_run) + task::handle_runner_entry(task_name, cli.args, cli.dry_run, cli.trace) } } diff --git a/src/runner/cache.rs b/src/runner/cache.rs index f7ee329..9ff98d3 100644 --- a/src/runner/cache.rs +++ b/src/runner/cache.rs @@ -3,6 +3,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::io::Read; use std::collections::HashMap; +use colored::*; const CACHE_DIR: &str = ".p/cache"; @@ -80,7 +81,7 @@ pub fn compute_hash(sources: &[String], env: &HashMap) -> Result Ok(hasher.finalize().to_hex().to_string()) } -pub fn is_up_to_date(task_name: &str, sources: &[String], outputs: &[String], env: &HashMap) -> Result { +pub fn is_up_to_date(task_name: &str, sources: &[String], outputs: &[String], env: &HashMap, trace: bool) -> Result { ensure_cache_setup()?; // 1. Check if all outputs exist @@ -103,6 +104,9 @@ pub fn is_up_to_date(task_name: &str, sources: &[String], outputs: &[String], en // If a pattern in 'outputs' yields NO files, we consider outputs missing. // e.g. outputs=["dist/bundle.js"]. If file missing, glob is empty. found_any=false. if !found_any { + if trace { + eprintln!("{} [TRACE] Cache miss for '{}': Output pattern '{}' matched no files.", "๐Ÿ”".blue(), task_name, pattern); + } return Ok(false); } } @@ -112,12 +116,25 @@ pub fn is_up_to_date(task_name: &str, sources: &[String], outputs: &[String], en let cache_path = get_cache_path(task_name); if !cache_path.exists() { + if trace { + eprintln!("{} [TRACE] Cache miss for '{}': No previous cache found.", "๐Ÿ”".blue(), task_name); + } return Ok(false); } let cached_hash = fs::read_to_string(cache_path)?; - Ok(current_hash.trim() == cached_hash.trim()) + if current_hash.trim() != cached_hash.trim() { + if trace { + eprintln!("{} [TRACE] Cache miss for '{}': Hash mismatch (sources or env changed).", "๐Ÿ”".blue(), task_name); + // Optional: Print hash diff if really needed, but mismatch reason is usually enough + eprintln!(" Current: {}", current_hash.trim()); + eprintln!(" Cached: {}", cached_hash.trim()); + } + return Ok(false); + } + + Ok(true) } pub fn save_cache(task_name: &str, sources: &[String], env: &HashMap) -> Result<()> { diff --git a/src/runner/mod.rs b/src/runner/mod.rs index f4f6a3f..b578c69 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -17,6 +17,7 @@ use self::cache::{is_up_to_date, save_cache}; use self::portable::run_portable_command; use log::{info, error}; use std::time::Instant; +use std::thread; pub struct CallStack { stack: HashSet, @@ -48,25 +49,192 @@ impl CallStack { } } +fn execute_command_list( + task_name: &str, + mut cmds: Vec, + config: &PavidiConfig, + extra_args: &[String], + capture_output: bool, + dry_run: bool, + shell_cmd: &str, + timeout_sec: Option, + retry: u32, + retry_delay: u64, + ignore_failure: bool, + trace: bool, + depth: usize, +) -> Result<()> { + if cmds.is_empty() { + return Ok(()); + } + + // Log configuration + let (log_strategy, _) = if let Some(p) = &config.project { + (p.log_strategy, p.log_plain) + } else if let Some(m) = &config.module { + (m.log_strategy, m.log_plain) + } else { + (None, None) + }; + let log_enabled = log_strategy.unwrap_or(crate::config::LogStrategy::None) != crate::config::LogStrategy::None; + + let capture_mode = if capture_output { + CaptureMode::Buffer + } else { + if log_enabled { + CaptureMode::Tee + } else { + CaptureMode::Inherit + } + }; + + let timeout_duration = match timeout_sec { + Some(0) => None, + Some(s) => Some(Duration::from_secs(s)), + None => Some(Duration::from_secs(1800)), + }; + + let retry_delay_duration = Duration::from_secs(retry_delay); + + for cmd in &mut cmds { + if trace { + let indent = " ".repeat(depth); + eprintln!("{} {} [TRACE] Raw command: '{}'", indent, "โš™๏ธ".cyan(), cmd); + } + + // Apply Argument Expansion ($1, $2...) and Env Var Interpolation + let final_cmd = expand_command(cmd, extra_args, &config.env); + + if trace { + let indent = " ".repeat(depth); + eprintln!("{} {} [TRACE] Expanded command: '{}'", indent, "โš™๏ธ".cyan(), final_cmd); + } + + if dry_run { + println!("{} [DRY-RUN] Executing: {}", "::".yellow(), final_cmd); + continue; + } + + if !capture_output { + info!("{} Executing: {}", "::".blue(), final_cmd); + } + + let mut attempt = 0; + + loop { + let start_time = Instant::now(); + let mut captured_output = String::new(); + let mut exit_code = 0; + let mut execution_failed = false; + let mut execution_error = String::new(); + + // Fallback to legacy portable/shell command + if final_cmd.trim_start().starts_with("p:") { + if let Err(e) = run_portable_command(&final_cmd, trace) { + execution_failed = true; + execution_error = e.to_string(); + exit_code = 1; + } + } else { + let result = run_shell_command(&final_cmd, &config.env, capture_mode, task_name, &shell_cmd, timeout_duration); + + match result { + Ok((code, output)) => { + captured_output = output; + exit_code = code; + if code != 0 { + execution_failed = true; + } + }, + Err(e) => { + execution_failed = true; + execution_error = e.to_string(); + exit_code = 1; + } + } + } + + if trace { + let indent = " ".repeat(depth); + eprintln!("{} {} [TRACE] Command finished in {:.2?}. Exit code: {}", indent, "โฑ๏ธ".cyan(), start_time.elapsed(), exit_code); + } + + if !execution_failed { + // Success + if log_enabled { + if let Ok(Some(path)) = write_log(task_name, &final_cmd, &captured_output, config, start_time.elapsed(), exit_code, &config.env) { + info!("{} Log saved: {}", "๐Ÿ“".dimmed(), path.display()); + } + } + break; + } else { + // Failure + if log_enabled { + let log_content = if !execution_error.is_empty() { + format!("Execution Error: {}", execution_error) + } else { + captured_output.clone() + }; + let _ = write_log(task_name, &final_cmd, &log_content, config, start_time.elapsed(), exit_code, &config.env); + } + + if attempt < retry { + attempt += 1; + if !capture_output { + info!("{} Command failed. Retrying ({}/{}) in {}s...", "๐Ÿ”„".yellow(), attempt, retry, retry_delay_duration.as_secs()); + } + thread::sleep(retry_delay_duration); + continue; + } else { + // All retries failed + if ignore_failure { + if !execution_error.is_empty() { + log::warn!("{} Command failed but ignored: {}", "โš ๏ธ".yellow(), execution_error); + } else { + log::warn!("{} Command failed but ignored (code {})", "โš ๏ธ".yellow(), exit_code); + } + break; + } else { + if !execution_error.is_empty() { + bail!("โŒ Task '{}' failed at: '{}' -> {}", task_name, final_cmd, execution_error); + } else { + bail!("โŒ Task '{}' failed at: '{}' -> Exit code {}", task_name, final_cmd, exit_code); + } + } + } + } + } // end loop + } // end for + Ok(()) +} + pub fn recursive_runner( task_name: &str, config: &PavidiConfig, call_stack: &mut CallStack, extra_args: &[String], capture_output: bool, // true = buffer output (for parallel), false = inherit - dry_run: bool + dry_run: bool, + trace: bool, + depth: usize, ) -> Result<()> { + if trace { + let indent = " ".repeat(depth); + eprintln!("{} [TRACE] Entering task: {}", indent, task_name.bold()); + } + let task_start = Instant::now(); + call_stack.push(task_name)?; let runner_section = config.runner.as_ref().unwrap(); let task = runner_section.get(task_name).expect("Task check passed before"); // Destructure task config - let (mut cmds, deps, parallel_deps, run_if, skip_if, sources, outputs, windows, linux, macos, ignore_failure, timeout_sec) = match task { - RunnerTask::Single(cmd) => (vec![cmd.clone()], vec![], false, None, None, None, None, None, None, None, false, None), - RunnerTask::List(cmds) => (cmds.clone(), vec![], false, None, None, None, None, None, None, None, false, None), - RunnerTask::Full { cmds, deps, parallel, run_if, skip_if, sources, outputs, windows, linux, macos, ignore_failure, timeout, .. } => - (cmds.clone(), deps.clone(), *parallel, run_if.clone(), skip_if.clone(), sources.clone(), outputs.clone(), windows.clone(), linux.clone(), macos.clone(), *ignore_failure, *timeout), + let (mut cmds, deps, parallel_deps, run_if, skip_if, sources, outputs, windows, linux, macos, ignore_failure, timeout_sec, retry, retry_delay, finally_cmds) = match task { + RunnerTask::Single(cmd) => (vec![cmd.clone()], vec![], false, None, None, None, None, None, None, None, false, None, None, None, None), + RunnerTask::List(cmds) => (cmds.clone(), vec![], false, None, None, None, None, None, None, None, false, None, None, None, None), + RunnerTask::Full { cmds, deps, parallel, run_if, skip_if, sources, outputs, windows, linux, macos, ignore_failure, timeout, retry, retry_delay, finally, .. } => + (cmds.clone(), deps.clone(), *parallel, run_if.clone(), skip_if.clone(), sources.clone(), outputs.clone(), windows.clone(), linux.clone(), macos.clone(), *ignore_failure, *timeout, *retry, *retry_delay, finally.clone()), }; // 1. Run Dependencies @@ -86,7 +254,8 @@ pub fn recursive_runner( let mut local_stack = stack_snapshot.clone_stack(); // Parallel deps MUST capture output to prevent mixed logs - recursive_runner(dep_name, config, &mut local_stack, &[], true, dry_run) + // Note: Depth increments for parallel tasks too, but trace output might be interleaved + recursive_runner(dep_name, config, &mut local_stack, &[], true, dry_run, trace, depth + 1) .map_err(|e| format!("Dep '{}' failed: {}", dep_name, e)) }) .filter_map(|res| res.err()) @@ -101,7 +270,7 @@ pub fn recursive_runner( info!("{} Running dependencies sequentially...", "๐Ÿ”—".blue()); } for dep in deps { - recursive_runner(&dep, config, call_stack, &[], capture_output, dry_run)?; + recursive_runner(&dep, config, call_stack, &[], capture_output, dry_run, trace, depth + 1)?; } } } @@ -117,6 +286,11 @@ pub fn recursive_runner( let cmd = expand_command(&raw_cmd, extra_args, &config.env); // Silent execution let (code, _) = run_shell_command(&cmd, &config.env, CaptureMode::Buffer, task_name, &shell_cmd, None)?; + + if trace { + eprintln!("{} [TRACE] skip_if check: '{}' -> exit code {}", " ".repeat(depth), cmd, code); + } + if code == 0 { if !capture_output { info!("{} Skipping task '{}' because 'skip_if' condition met.", "โญ๏ธ".yellow(), task_name.bold()); @@ -131,6 +305,11 @@ pub fn recursive_runner( let cmd = expand_command(&raw_cmd, extra_args, &config.env); // Silent execution let (code, _) = run_shell_command(&cmd, &config.env, CaptureMode::Buffer, task_name, &shell_cmd, None)?; + + if trace { + eprintln!("{} [TRACE] run_if check: '{}' -> exit code {}", " ".repeat(depth), cmd, code); + } + if code != 0 { if !capture_output { info!("{} Skipping task '{}' because 'run_if' condition failed.", "โญ๏ธ".yellow(), task_name.bold()); @@ -142,7 +321,7 @@ pub fn recursive_runner( // 3. Check Conditional Execution (Cache Check) if let (Some(srcs), Some(outs)) = (&sources, &outputs) { - if is_up_to_date(task_name, srcs, outs, &config.env)? { + if is_up_to_date(task_name, srcs, outs, &config.env, trace)? { if !capture_output { info!("{} Task '{}' is up-to-date. Skipping.", "โœจ".green(), task_name.bold()); } @@ -162,6 +341,11 @@ pub fn recursive_runner( _ => None, }; + if trace { + let selected = if os_cmds.is_some() { os } else { "default" }; + eprintln!("{} [TRACE] OS Selection: System is '{}'. Selected commands from: '{}'", " ".repeat(depth), os, selected); + } + if let Some(c) = os_cmds { cmds = c.clone(); } @@ -171,111 +355,63 @@ pub fn recursive_runner( bail!("No commands defined for this OS ({})", os); } - if !cmds.is_empty() { - if !capture_output { - info!("{} Running task: {}", "โšก".yellow(), task_name.bold()); - } - - // Log configuration - let (log_strategy, _) = if let Some(p) = &config.project { - (p.log_strategy, p.log_plain) - } else if let Some(m) = &config.module { - (m.log_strategy, m.log_plain) - } else { - (None, None) - }; - let log_enabled = log_strategy.unwrap_or(crate::config::LogStrategy::None) != crate::config::LogStrategy::None; - - let capture_mode = if capture_output { - CaptureMode::Buffer - } else { - if log_enabled { - CaptureMode::Tee - } else { - CaptureMode::Inherit - } - }; - - // Note: shell_cmd was already detected above, reusing it. - - let timeout_duration = match timeout_sec { - Some(0) => None, - Some(s) => Some(Duration::from_secs(s)), - None => Some(Duration::from_secs(1800)), - }; - - for cmd in &mut cmds { - // Apply Argument Expansion ($1, $2...) and Env Var Interpolation - let final_cmd = expand_command(cmd, extra_args, &config.env); - - if dry_run { - println!("{} [DRY-RUN] Executing: {}", "::".yellow(), final_cmd); - continue; - } + if !capture_output && !cmds.is_empty() { + info!("{} Running task: {}", "โšก".yellow(), task_name.bold()); + } - if !capture_output { - info!("{} Executing: {}", "::".blue(), final_cmd); - } + let main_result = execute_command_list( + task_name, + cmds, + config, + extra_args, + capture_output, + dry_run, + &shell_cmd, + timeout_sec, + retry.unwrap_or(0), + retry_delay.unwrap_or(0), + ignore_failure, + trace, + depth + ); - let start_time = Instant::now(); - let mut captured_output = String::new(); - let mut exit_code = 0; + // 5. Execute Finally Commands + let mut finally_result = Ok(()); + if let Some(f_cmds) = finally_cmds { + if !capture_output { + info!("{} Running cleanup for: {}", "๐Ÿงน".magenta(), task_name.bold()); + } + finally_result = execute_command_list( + task_name, + f_cmds, + config, + extra_args, + capture_output, + dry_run, + &shell_cmd, + timeout_sec, + 0, + 0, + false, + trace, + depth + ); + } + + call_stack.pop(task_name); - // Fallback to legacy portable/shell command - if final_cmd.trim_start().starts_with("p:") { - if let Err(e) = run_portable_command(&final_cmd) { - if ignore_failure { - log::warn!("{} Command failed but ignored: {}", "โš ๏ธ".yellow(), e); - continue; - } - bail!("โŒ Task '{}' failed at: '{}' -> {}", task_name, final_cmd, e); - } - } else { - let result = run_shell_command(&final_cmd, &config.env, capture_mode, task_name, &shell_cmd, timeout_duration); - - match result { - Ok((code, output)) => { - captured_output = output; - exit_code = code; - if code != 0 { - if log_enabled { - let _ = write_log(task_name, &final_cmd, &captured_output, config, start_time.elapsed(), code, &config.env); - } - if ignore_failure { - log::warn!("{} Command failed but ignored (code {})", "โš ๏ธ".yellow(), code); - } else { - bail!("โŒ Task '{}' failed at: '{}' -> Exit code {}", task_name, final_cmd, code); - } - } - }, - Err(e) => { - // Execution error (timeout, etc) - if log_enabled { - let _ = write_log(task_name, &final_cmd, &format!("Execution Error: {}", e), config, start_time.elapsed(), 1, &config.env); - } - if ignore_failure { - log::warn!("{} Command failed but ignored: {}", "โš ๏ธ".yellow(), e); - continue; - } - bail!("โŒ Task '{}' failed at: '{}' -> {}", task_name, final_cmd, e); - } - } + match (main_result, finally_result) { + (Err(e), _) => Err(e), + (Ok(_), Err(e)) => Err(e), + (Ok(_), Ok(_)) => { + // Success: Update cache if sources AND outputs defined + if let (Some(srcs), Some(_)) = (&sources, &outputs) { + save_cache(task_name, srcs, &config.env)?; } - - - if log_enabled { - if let Ok(Some(path)) = write_log(task_name, &final_cmd, &captured_output, config, start_time.elapsed(), exit_code, &config.env) { - info!("{} Log saved: {}", "๐Ÿ“".dimmed(), path.display()); - } + if trace { + eprintln!("{} [TRACE] Exiting task: {} (Duration: {:.2?})", " ".repeat(depth), task_name.bold(), task_start.elapsed()); } - } - - // Success: Update cache if sources AND outputs defined (otherwise we never check it anyway) - if let (Some(srcs), Some(_)) = (&sources, &outputs) { - save_cache(task_name, srcs, &config.env)?; + Ok(()) } } - - call_stack.pop(task_name); - Ok(()) } diff --git a/src/runner/portable.rs b/src/runner/portable.rs index 3d0d5cf..0f74e14 100644 --- a/src/runner/portable.rs +++ b/src/runner/portable.rs @@ -5,14 +5,20 @@ use crate::runner::handler::rm::handle_rm; use crate::runner::handler::ls::handle_ls; use crate::runner::handler::mv::handle_mv; use crate::runner::handler::cat::handle_cat; +use colored::*; -pub fn run_portable_command(cmd_str: &str) -> Result<()> { +pub fn run_portable_command(cmd_str: &str, trace: bool) -> Result<()> { let args = shell_words::split(cmd_str).context("Failed to parse portable command arguments")?; if args.is_empty() { return Ok(()); } let command = &args[0]; + + if trace { + eprintln!("{} [TRACE] Portable command: {}", "โš™๏ธ".cyan(), cmd_str); + } + match command.as_str() { "p:rm" => handle_rm(&args[1..]), "p:mkdir" => handle_mkdir(&args[1..]), diff --git a/src/runner/task.rs b/src/runner/task.rs index e27fa85..2861ee2 100644 --- a/src/runner/task.rs +++ b/src/runner/task.rs @@ -34,8 +34,18 @@ pub enum RunnerTask { #[serde(default)] ignore_failure: bool, + // Retry Logic + #[serde(default)] + retry: Option, + #[serde(default)] + retry_delay: Option, + // Timeout (seconds) #[serde(default)] timeout: Option, + + // Finally/Cleanup + #[serde(default)] + finally: Option>, }, } diff --git a/src/utils.rs b/src/utils.rs index 14eeda0..a459227 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -23,10 +23,17 @@ pub enum CaptureMode { /// Fallback for args: If no placeholders found, append args to the end. pub fn expand_command(cmd_template: &str, args: &[String], env_vars: &HashMap) -> String { let mut expanded = cmd_template.to_string(); + let mut replaced_args = false; + + // 0. Argument Splat ($@) + // If the command contains $@, replace it with all arguments joined by space + if expanded.contains("$@") { + expanded = expanded.replace("$@", &args.join(" ")); + replaced_args = true; + } // 1. Argument Substitution ($1, $2...) if !args.is_empty() { - let mut replaced_args = false; for (i, arg) in args.iter().enumerate() { let placeholder = format!("${}", i + 1); if expanded.contains(&placeholder) { @@ -35,7 +42,7 @@ pub fn expand_command(cmd_template: &str, args: &[String], env_vars: &HashMap) -> String { "sh".to_string() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_expand_command_legacy_append() { + let cmd = "echo hello"; + let args = vec!["world".to_string()]; + let env = HashMap::new(); + let expanded = expand_command(cmd, &args, &env); + assert_eq!(expanded, "echo hello world"); + } + + #[test] + fn test_expand_command_positional_args() { + let cmd = "echo $1 $2"; + let args = vec!["hello".to_string(), "world".to_string()]; + let env = HashMap::new(); + let expanded = expand_command(cmd, &args, &env); + assert_eq!(expanded, "echo hello world"); + } + + #[test] + fn test_expand_command_splat_args() { + let cmd = "echo $@ end"; + let args = vec!["hello".to_string(), "world".to_string()]; + let env = HashMap::new(); + let expanded = expand_command(cmd, &args, &env); + assert_eq!(expanded, "echo hello world end"); + } + + #[test] + fn test_expand_command_splat_args_no_args() { + let cmd = "echo $@ end"; + let args = vec![]; + let env = HashMap::new(); + let expanded = expand_command(cmd, &args, &env); + assert_eq!(expanded, "echo end"); // Note the double space, depends on join empty logic + } + + #[test] + fn test_expand_command_splat_overrides_append() { + let cmd = "echo $@"; + let args = vec!["hello".to_string()]; + let env = HashMap::new(); + let expanded = expand_command(cmd, &args, &env); + assert_eq!(expanded, "echo hello"); + // Should NOT be "echo hello hello" + } + + #[test] + fn test_expand_command_env_vars() { + let cmd = "echo $MY_VAR"; + let args = vec![]; + let mut env = HashMap::new(); + env.insert("MY_VAR".to_string(), "value".to_string()); + let expanded = expand_command(cmd, &args, &env); + assert_eq!(expanded, "echo value"); + } + + #[test] + fn test_expand_command_mixed_splat_and_env() { + let cmd = "echo $@ $MY_VAR"; + let args = vec!["arg1".to_string()]; + let mut env = HashMap::new(); + env.insert("MY_VAR".to_string(), "value".to_string()); + let expanded = expand_command(cmd, &args, &env); + assert_eq!(expanded, "echo arg1 value"); + } +} diff --git a/wix/main.wxs b/wix/main.wxs deleted file mode 100644 index 104ddee..0000000 --- a/wix/main.wxs +++ /dev/null @@ -1,228 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - 1 - - - - - - - - - - - - - - - - - -