diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c59af5..a5fe0a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -278,14 +278,61 @@ jobs: gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + publish-homebrew-formula: + needs: + - plan + - host + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PLAN: ${{ needs.plan.outputs.val }} + GITHUB_USER: "axo bot" + GITHUB_EMAIL: "admin+bot@axo.dev" + if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: true + repository: "CodeTease/homebrew-tap" + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + # So we have access to the formula + - name: Fetch homebrew formulae + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: Formula/ + merge-multiple: true + # This is extra complex because you can make your Formula name not match your app name + # so we need to find releases with a *.rb file, and publish with that filename. + - name: Commit formula files + run: | + git config --global user.name "${GITHUB_USER}" + git config --global user.email "${GITHUB_EMAIL}" + + for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do + filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output) + name=$(echo "$filename" | sed "s/\.rb$//") + version=$(echo "$release" | jq .app_version --raw-output) + + export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH" + brew update + # We avoid reformatting user-provided data such as the app description and homepage. + brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true + + git add "Formula/${filename}" + git commit -m "${name} ${version}" + done + git push + announce: needs: - plan - host + - publish-homebrew-formula # use "always() && ..." to allow us to wait for all publish jobs while # still allowing individual publish jobs to skip themselves (for prereleases). # "host" however must run to completion, no skipping allowed! - if: ${{ always() && needs.host.result == 'success' }} + if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') }} runs-on: "ubuntu-22.04" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 1702cdc..5f6537a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,7 +56,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -67,7 +67,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -131,9 +131,9 @@ checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "cc" -version = "1.2.54" +version = "1.2.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ "find-msvc-tools", "shlex", @@ -167,9 +167,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.54" +version = "4.5.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" dependencies = [ "clap_builder", "clap_derive", @@ -177,9 +177,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.5.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" dependencies = [ "anstream", "anstyle", @@ -189,9 +189,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", @@ -201,9 +201,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "colorchoice" @@ -213,11 +213,11 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "colored" -version = "3.0.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys", ] [[package]] @@ -274,7 +274,7 @@ checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790" dependencies = [ "dispatch2", "nix", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -343,14 +343,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "find-msvc-tools" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "glob" @@ -468,12 +468,6 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "nix" version = "0.30.1" @@ -486,16 +480,6 @@ dependencies = [ "libc", ] -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -532,19 +516,9 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "os_pipe" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "pavidi" -version = "0.1.0-preview.1" +version = "0.1.0-preview.2" dependencies = [ "anyhow", "blake3", @@ -556,8 +530,6 @@ dependencies = [ "env_logger", "glob", "log", - "nom", - "os_pipe", "rayon", "regex", "serde", @@ -569,33 +541,33 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" dependencies = [ "portable-atomic", ] [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -622,9 +594,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -634,9 +606,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -645,9 +617,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "rustix" @@ -659,7 +631,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -738,9 +710,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.10+spec-1.1.0" +version = "0.9.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" dependencies = [ "indexmap", "serde_core", @@ -911,15 +883,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -929,70 +892,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - [[package]] name = "winnow" version = "0.7.14" diff --git a/Cargo.toml b/Cargo.toml index aefcf13..9505d3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,19 +1,26 @@ [package] name = "pavidi" -version = "0.1.0-preview.1" -description = "A project manager." +version = "0.1.0-preview.2" +description = "A task runner." license = "Apache-2.0" readme = "README.md" edition = "2024" authors = ["CodeTease"] repository = "https://github.com/CodeTease/p" +homepage = "https://github.com/CodeTease/p/blob/main/README.md" + +[package.metadata.wix] +upgrade-guid = "100DB47A-429D-428B-95DF-214CCFC773A9" +path-guid = "E3F41D03-35F5-44CA-B2A6-7D8AD351941E" +license = false +eula = false [[bin]] name = "p" path = "src/main.rs" [dependencies] -# CLI Argument Parser (v4 still good) +# CLI Argument Parser clap = { version = "4.5", features = ["derive"] } # Serialization/Deserialization @@ -50,12 +57,6 @@ blake3 = "1.8" # Regex for Env Var Interpolation regex = "1.12" -# Parser Combinator -nom = "7.1" - -# OS Pipes -os_pipe = "1.2" - # Signal Handling ctrlc = "3.5" chrono = { version = "0.4", features = ["serde"] } diff --git a/README.md b/README.md index da652e9..181728b 100644 --- a/README.md +++ b/README.md @@ -3,18 +3,17 @@ > **PREVIEW STAGE** > This software is currently in preview. Features and APIs are subject to change. Use with caution. -Pavidi (or simply **P**) is a minimalist task runner and shell environment built in Rust. It aims to provide a consistent execution layer across different operating systems. +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. ## Components - **Pavidi (Core):** The project-aware task runner that manages configuration, dependencies, and execution flow. -- **PaS (PaShell):** A custom, cross-platform shell embedded within Pavidi. It ensures that commands run identically on Linux, macOS, and Windows without relying on system-specific shells like Bash or PowerShell. ## Installation To build and install from source: -```bash +```sh cargo install --path . ``` @@ -52,27 +51,17 @@ ignore_failure = false P uses short, mnemonic commands for efficiency. - **Run a task:** - ```bash - p r - # Example: p r build + ```sh + p + # Example: p build ``` - **List available tasks:** - ```bash - p ls - ``` - -- **Start the PaShell REPL:** - ```bash - p sh + ```sh + p -l ``` - **Show project info:** - ```bash - p info - ``` - -- **Clean artifacts:** - ```bash - p c + ```sh + p -i ``` diff --git a/dist-workspace.toml b/dist-workspace.toml index 3adbd46..45e7e34 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -8,10 +8,14 @@ cargo-dist-version = "0.30.3" # CI backends to support ci = "github" # The installers to generate for each app -installers = ["shell", "powershell"] +installers = ["shell", "powershell", "homebrew", "msi"] # Target platforms to build apps for (Rust target-triple syntax) 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 -install-updater = false +install-updater = true +# A GitHub repo to push Homebrew formulas to +tap = "CodeTease/homebrew-tap" +# Publish jobs to run in CI +publish-jobs = ["homebrew"] diff --git a/scripts/init.ps1 b/scripts/init.ps1 deleted file mode 100644 index e1a9ad1..0000000 --- a/scripts/init.ps1 +++ /dev/null @@ -1,29 +0,0 @@ -function p { - # If the first argument is 'j' (jump), handle it specially - if ($args[0] -eq "j") { - $path = $args[1] - if (-not $path) { - Write-Host "Usage: p j " - return - } - - $tmpFile = [System.IO.Path]::GetTempFileName() - - $env:PAVIDI_OUTPUT = $tmpFile - # Pass 'j' and the path explicitly - & p.exe j $path - $env:PAVIDI_OUTPUT = $null - - if ($LASTEXITCODE -eq 0) { - $targetDir = Get-Content $tmpFile - if (Test-Path $targetDir) { - Set-Location $targetDir - } - } - - Remove-Item $tmpFile - } else { - # For all other commands, pass all arguments through directly - & p.exe @args - } -} diff --git a/scripts/init.sh b/scripts/init.sh deleted file mode 100644 index b3a1fe9..0000000 --- a/scripts/init.sh +++ /dev/null @@ -1,26 +0,0 @@ -p() { - if [ "$1" = "j" ]; then - if [ -z "$2" ]; then - echo "Usage: p j " - return 1 - fi - - # Temp file to capture output - local tmp_file=$(mktemp) - - # Run Pavidi with PAVIDI_OUTPUT env var - PAVIDI_OUTPUT="$tmp_file" command p j "$2" - - # Check if pavidi succeeded - if [ $? -eq 0 ]; then - local target_dir=$(cat "$tmp_file") - if [ -d "$target_dir" ]; then - cd "$target_dir" - fi - fi - - rm -f "$tmp_file" - else - command p "$@" - fi -} diff --git a/src/cli.rs b/src/cli.rs index cc9451e..4d1cb39 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,54 +1,40 @@ -use clap::{Parser, Subcommand}; -use std::path::PathBuf; +use clap::Parser; #[derive(Parser)] #[command(name = "p", version, about = "Pavidi: Minimalist Project Runner")] pub struct Cli { - #[command(subcommand)] - pub command: Commands, -} - -#[derive(Subcommand)] -pub enum Commands { - /// Enter a project's shell environment (Sub-shell session) - D { path: PathBuf }, - - /// Start the PaShell interactive REPL (Demo/Testing only) - #[command(visible_alias = "sh")] - Shell, - /// List all available tasks - #[command(visible_alias = "ls")] - List, - - /// Run a task defined in p.toml - R { - #[arg(default_value = "default")] - task: String, - - /// Run in dry-run mode (print commands without executing) - #[arg(short = 'd', long = "dry-run")] - dry_run: bool, - - #[arg(last = true)] - args: Vec, - }, - - /// Clean artifacts defined in p.toml - C, - - /// Jump to a directory (Resolve path for shell hook) - J { path: PathBuf }, - - /// Initialize shell hooks - I { - #[arg(default_value = "zsh")] - shell: String - }, + #[arg(short, long)] + pub list: bool, /// Inspect environment variables - E, + #[arg(short, long)] + pub env: bool, /// Show project/module metadata - Info, + #[arg(short = 'i', long = "info")] + pub info: bool, + + /// Run in dry-run mode (print commands without executing) + #[arg(short = 'd', long = "dry-run")] + pub dry_run: bool, + + /// The task to run (defaults to "default") + #[arg(name = "TASK")] + pub task: Option, + + /// Arguments to pass to the task + #[arg(last = true)] + pub args: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn verify_cli() { + Cli::command().debug_assert(); + } } diff --git a/src/config.rs b/src/config.rs index a9a89ac..11894a6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,17 +6,17 @@ use std::fs; use std::path::Path; use std::env; use crate::runner::task::RunnerTask; +use regex::Regex; +use crate::utils::{run_shell_command, CaptureMode, detect_shell}; #[derive(Debug, Deserialize)] pub struct PavidiConfig { pub project: Option, pub module: Option, pub capability: Option, - pub pas: Option, #[serde(default)] pub env: HashMap, pub runner: Option>, - pub clean: Option, } #[derive(Debug, Deserialize, Clone)] @@ -55,25 +55,9 @@ pub struct ModuleConfig { #[derive(Debug, Deserialize, Clone)] pub struct CapabilityConfig { - pub allow_exec: Option>, pub allow_paths: Option>, } -#[derive(Debug, Deserialize, Clone)] -pub struct PasConfig { - pub profile: Option, -} - -#[derive(Debug, Deserialize, Clone)] -pub struct PasProfileConfig { - pub startup: Option>, -} - -#[derive(Debug, Deserialize)] -pub struct CleanConfig { - pub targets: Vec, -} - pub fn load_config(dir: &Path) -> Result { let config_path = dir.join("p.toml"); if !config_path.exists() { @@ -124,5 +108,37 @@ pub fn load_config(dir: &Path) -> Result { } } + // 3. Dynamic Env Var Resolution + let shell_pref = config.project.as_ref().and_then(|p| p.shell.as_ref()) + .or(config.module.as_ref().and_then(|m| m.shell.as_ref())); + let shell = detect_shell(shell_pref); + + let re = Regex::new(r"^\$\((.*)\)$").unwrap(); + let mut updates = HashMap::new(); + + for (k, v) in &config.env { + if let Some(caps) = re.captures(v) { + let cmd = caps.get(1).map(|m| m.as_str()).unwrap_or(""); + if !cmd.trim().is_empty() { + // Execute command + let (code, output) = run_shell_command( + cmd, + &config.env, + CaptureMode::Buffer, + &format!("env:{}", k), + &shell, + None + )?; + + if code != 0 { + bail!("❌ Failed to resolve dynamic environment variable '{}': Command '{}' failed with exit code {}.", k, cmd, code); + } + + updates.insert(k.clone(), output.trim().to_string()); + } + } + } + config.env.extend(updates); + Ok(config) } diff --git a/src/handlers/clean.rs b/src/handlers/clean.rs deleted file mode 100644 index c159ffa..0000000 --- a/src/handlers/clean.rs +++ /dev/null @@ -1,28 +0,0 @@ -use anyhow::{Context, Result}; -use colored::*; -use std::fs; -use std::env; -use crate::config::load_config; - -pub fn handle_clean() -> Result<()> { - let current_dir = env::current_dir()?; - let config = load_config(¤t_dir)?; - let clean_section = config.clean.context("No [clean] section defined in config")?; - - println!("{} Cleaning targets...", "🧹".red()); - for pattern in clean_section.targets { - let full_pattern = format!("{}/{}", current_dir.to_string_lossy(), pattern); - for entry in glob::glob(&full_pattern)? { - if let Ok(path) = entry { - if path.is_dir() { - fs::remove_dir_all(&path)?; - println!(" Deleted dir: {:?}", path.file_name().unwrap()); - } else { - fs::remove_file(&path)?; - println!(" Deleted file: {:?}", path.file_name().unwrap()); - } - } - } - } - Ok(()) -} diff --git a/src/handlers/info.rs b/src/handlers/info.rs index e70155f..ba552a1 100644 --- a/src/handlers/info.rs +++ b/src/handlers/info.rs @@ -1,46 +1,40 @@ use anyhow::Result; -use std::env; use colored::*; -use crate::config::load_config; +use std::env; +use crate::config::{load_config, Metadata}; pub fn handle_info() -> Result<()> { let current_dir = env::current_dir()?; - // We ignore error here? No, load_config returns Result. - // But maybe we want to show info even if partial? - // load_config handles missing file by erroring. let config = load_config(¤t_dir)?; - println!(); - if let Some(project) = config.project { - println!("{}", "📦 PROJECT SCOPE".green().bold()); - println!("{}", "================".green()); - print_metadata(&project.metadata); - } else if let Some(module) = config.module { - println!("{}", "🧩 MODULE SCOPE".cyan().bold()); - println!("{}", "===============".cyan()); - print_metadata(&module.metadata); + let metadata: Option<&Metadata> = if let Some(p) = &config.project { + Some(&p.metadata) + } else if let Some(m) = &config.module { + Some(&m.metadata) } else { - // This case should be caught by load_config validation technically if we enforced presence. - // But structs are Option. - println!("{}", "⚠️ No [project] or [module] definition found.".yellow()); + None + }; + + 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); + } + if let Some(authors) = &meta.authors { + if !authors.is_empty() { + println!("{}: {}", "Authors".cyan(), authors.join(", ")); + } + } + } else { + println!("{}", "No project/module metadata found.".yellow()); } - println!(); Ok(()) } - -fn print_metadata(meta: &crate::config::Metadata) { - if let Some(name) = &meta.name { - println!(" Name: {}", name.bold()); - } - if let Some(ver) = &meta.version { - println!(" Version: {}", ver); - } - if let Some(desc) = &meta.description { - println!(" Description: {}", desc.italic()); - } - if let Some(authors) = &meta.authors { - let joined = authors.join(", "); - println!(" Authors: {}", joined); - } -} diff --git a/src/handlers/init.rs b/src/handlers/init.rs deleted file mode 100644 index 16ad10e..0000000 --- a/src/handlers/init.rs +++ /dev/null @@ -1,11 +0,0 @@ -use anyhow::Result; - -pub fn handle_init(shell: &str) -> Result<()> { - let script = match shell { - "zsh" | "bash" => include_str!("../../scripts/init.sh"), - "powershell" | "pwsh" => include_str!("../../scripts/init.ps1"), - _ => "echo 'Unsupported shell'", - }; - println!("{}", script); - Ok(()) -} diff --git a/src/handlers/jump.rs b/src/handlers/jump.rs deleted file mode 100644 index 1774071..0000000 --- a/src/handlers/jump.rs +++ /dev/null @@ -1,23 +0,0 @@ -use anyhow::{Context, Result, bail}; -use std::fs; -use std::env; -use std::path::PathBuf; - -pub fn handle_jump(target_path: PathBuf) -> Result<()> { - if !target_path.exists() { - bail!("Path does not exist: {:?}", target_path); - } - - let abs_path = fs::canonicalize(&target_path) - .context("Failed to resolve path")?; - - // Output for external tools (like shell aliases) to capture the path - if let Ok(output_file) = env::var("PAVIDI_OUTPUT") { - fs::write(output_file, abs_path.to_string_lossy().as_bytes()) - .context("Failed to write jump path")?; - } else { - println!("{}", abs_path.to_string_lossy()); - } - - Ok(()) -} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 1c210bb..f64d07c 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,8 +1,4 @@ -pub mod shell; pub mod task; -pub mod clean; -pub mod init; -pub mod jump; pub mod env; pub mod list; pub mod info; diff --git a/src/handlers/shell.rs b/src/handlers/shell.rs deleted file mode 100644 index 94d425c..0000000 --- a/src/handlers/shell.rs +++ /dev/null @@ -1,125 +0,0 @@ -use anyhow::{Context, Result, bail}; -use colored::*; -use std::fs; -use std::path::{PathBuf}; -use std::process::{Command, Stdio}; -use std::env; -use std::io::{self, Write}; -use crate::config::load_config; -use crate::utils::detect_shell; -use crate::pas; - -pub fn handle_repl() -> Result<()> { - // Signal handling: catch Ctrl+C to prevent shell exit - ctrlc::set_handler(move || { - print!("\n> "); - io::stdout().flush().ok(); - }).context("Error setting Ctrl-C handler")?; - - // Load Config (Fail Closed) - let current_dir = env::current_dir()?; - let config_res = load_config(¤t_dir); - - let config = match config_res { - Ok(c) => Some(c), - Err(e) => { - if current_dir.join("p.toml").exists() { - eprintln!("{} Configuration Error: {}", "❌".red(), e); - bail!("Aborting shell session because p.toml exists but cannot be loaded. Fix the configuration to ensure security rules are applied."); - } - None - } - }; - - let capabilities = config.as_ref().and_then(|c| c.capability.clone()); - - let mut ctx = pas::context::ShellContext::new(capabilities); - - // Startup Profile - if let Some(cfg) = &config { - if let Some(pas_cfg) = &cfg.pas { - if let Some(profile) = &pas_cfg.profile { - if let Some(startup) = &profile.startup { - println!("{}", "Initializing environment...".dimmed()); - for cmd in startup { - match pas::run_command_line(cmd, &mut ctx, None, None) { - Ok(_) => {}, - Err(e) => eprintln!("{} Startup command failed: {}", "⚠️".yellow(), e), - } - } - } - } - } - } - - println!("Welcome to PaShell! It's just PaShell, not Pavidi Shell!"); - println!("Note: This REPL is just for testing PaS's functions. It's not the way you use PaS"); - println!("Type 'exit' to quit."); - - loop { - print!("> "); - io::stdout().flush()?; - - let mut input = String::new(); - if io::stdin().read_line(&mut input)? == 0 { - break; // EOF - } - - let input = input.trim(); - if input.is_empty() { - continue; - } - - if input == "exit" { - break; - } - - // Run - match pas::run_command_line(input, &mut ctx, None, None) { - Ok(_) => {}, // Exit code stored in ctx - Err(e) => eprintln!("Error: {}", e), - } - } - Ok(()) -} - -pub fn handle_dir_jump(target_path: PathBuf) -> Result<()> { - if !target_path.exists() || !target_path.is_dir() { - bail!("Target directory does not exist: {:?}", target_path); - } - - let abs_path = fs::canonicalize(&target_path)?; - // Now config includes merged envs from p.toml and .env - let config = load_config(&abs_path)?; - - // Detect shell preference or fallback to system default - let shell_pref = config.project.as_ref().and_then(|p| p.shell.as_ref()) - .or(config.module.as_ref().and_then(|m| m.shell.as_ref())); - let shell_cmd = detect_shell(shell_pref); - - eprintln!("{} Entering environment at: {}", "⤵️".cyan(), abs_path.display()); - - let mut command = Command::new(&shell_cmd); - command.current_dir(&abs_path) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .envs(&config.env); // Inject merged envs - - let status = command.status() - .context(format!("Failed to spawn shell: {}", shell_cmd))?; - - if !status.success() { - eprintln!("{} Shell exited with non-zero code.", "⚠️".yellow()); - } - - // Output for external tools (like shell aliases) to capture the path - if let Ok(output_file) = env::var("PAVIDI_OUTPUT") { - fs::write(output_file, abs_path.to_string_lossy().as_bytes()) - .context("Failed to write jump path")?; - } else { - println!("{}", abs_path.to_string_lossy()); - } - - Ok(()) -} diff --git a/src/handlers/task.rs b/src/handlers/task.rs index f415319..3e83182 100644 --- a/src/handlers/task.rs +++ b/src/handlers/task.rs @@ -3,7 +3,6 @@ use std::env; use std::sync::Arc; use crate::config::load_config; use crate::runner::{recursive_runner, CallStack}; -use crate::pas::context::ShellContext; pub fn handle_runner_entry(task_name: String, extra_args: Vec, dry_run: bool) -> Result<()> { let current_dir = env::current_dir()?; @@ -19,23 +18,6 @@ pub fn handle_runner_entry(task_name: String, extra_args: Vec, dry_run: let mut call_stack = CallStack::new(); - // Initialize Shell Context - let mut ctx = ShellContext::new(config_arc.capability.clone()); - - // Register builtins (Already done in new(), but handle_runner_entry was calling it manually? - // Reading context.rs: new() calls register_all_builtins. - // In previous task.rs, it called it again? - // "let mut ctx = ShellContext::new(); register_all_builtins(&mut ctx);" - // If ShellContext::new() calls it, calling it again might duplicate or panic if registry logic isn't idempotent. - // ShellContext::new() calls register_all_builtins. - // context.rs: "crate::pas::commands::builtins::register_all_builtins(&mut ctx);" inside new(). - // So I should remove the explicit call here if it's redundant. - // However, looking at previous task.rs: - // "let mut ctx = ShellContext::new(); register_all_builtins(&mut ctx);" - // If I check context.rs again: - // impl ShellContext { pub fn new() ... register_all_builtins ... } - // So yes, it's redundant. I will remove it. - // 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, Some(&mut ctx)) + recursive_runner(&task_name, &config_arc, &mut call_stack, &extra_args, false, dry_run) } diff --git a/src/main.rs b/src/main.rs index 817a4d1..39e59f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,26 +4,24 @@ mod runner; mod handlers; mod utils; mod logger; -pub mod pas; use anyhow::Result; use clap::Parser; -use cli::{Cli, Commands}; -use handlers::{shell, task, clean, jump, init, env, list, info}; +use cli::Cli; +use handlers::{task, env, list, info}; fn main() -> Result<()> { env_logger::init(); let cli = Cli::parse(); - match cli.command { - Commands::D { path } => shell::handle_dir_jump(path), - Commands::Shell => shell::handle_repl(), - Commands::List => list::handle_list(), - Commands::R { task, dry_run, args } => task::handle_runner_entry(task, args, dry_run), - Commands::C => clean::handle_clean(), - Commands::J { path } => jump::handle_jump(path), - Commands::I { shell } => init::handle_init(&shell), - Commands::E => env::handle_env(), - Commands::Info => info::handle_info(), + if cli.list { + list::handle_list() + } else if cli.info { + info::handle_info() + } else if cli.env { + env::handle_env() + } else { + let task_name = cli.task.unwrap_or_else(|| "default".to_string()); + task::handle_runner_entry(task_name, cli.args, cli.dry_run) } } diff --git a/src/pas/ast.rs b/src/pas/ast.rs deleted file mode 100644 index 5408dac..0000000 --- a/src/pas/ast.rs +++ /dev/null @@ -1,61 +0,0 @@ -#[derive(Debug, Clone, PartialEq)] -pub enum ArgPart { - Literal(String), - Variable(String), -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Arg(pub Vec); - -#[derive(Debug, Clone, PartialEq)] -pub enum CommandExpr { - // Simple command: "echo hello" - Simple { - program: Arg, - args: Vec, - }, - // Pipeline: "ls | grep target" - Pipe { - left: Box, - right: Box, - }, - // Redirection: "echo logs > file.txt" - Redirect { - cmd: Box, - target: Arg, - mode: RedirectMode, // Create, Append, Input - source_fd: i32, // 1 for stdout, 2 for stderr (default depends on context) - }, - // Logic AND: "cargo build && cargo run" - And(Box, Box), - // Logic OR: "cargo test || echo failed" - Or(Box, Box), - // Assignment: "A=10" - Assignment { - key: String, - value: Arg, - }, - // Subshell: "( cd /tmp; ls )" - Subshell(Box), - // If: "if true; then echo yes; else echo no; fi" - If { - cond: Box, - then_branch: Box, - else_branch: Option>, - }, - // While: "while true; do echo loop; done" - While { - cond: Box, - body: Box, - }, - // Sequence: "cmd1; cmd2" - Sequence(Box, Box), -} - -#[derive(Debug, Clone, PartialEq)] -pub enum RedirectMode { - Overwrite, // > - Append, // >> - Input, // < - MergeStderrToStdout, // 2>&1 -} diff --git a/src/pas/commands/adapter.rs b/src/pas/commands/adapter.rs deleted file mode 100644 index 268dbff..0000000 --- a/src/pas/commands/adapter.rs +++ /dev/null @@ -1,34 +0,0 @@ -// TaskRunnerAdapter -use crate::pas::commands::Executable; -use crate::pas::context::ShellContext; -use crate::config::PavidiConfig; -use crate::runner::{recursive_runner, CallStack}; -use anyhow::Result; -use std::sync::Arc; -use std::io::{Read, Write}; - -pub struct TaskRunnerAdapter { - pub task_name: String, - pub config: Arc, -} - -impl Executable for TaskRunnerAdapter { - fn execute(&self, args: &[String], ctx: &mut ShellContext, _stdin: Option>, _stdout: Option>, _stderr: Option>) -> Result { - let extra_args = args.iter().skip(1).cloned().collect::>(); - let mut call_stack = CallStack::new(); - - // Calls recursive_runner with the context. - // We assume recursive_runner has been updated to accept &mut ShellContext. - recursive_runner( - &self.task_name, - &self.config, - &mut call_stack, - &extra_args, - false, - false, - Some(ctx) - )?; - - Ok(0) - } -} diff --git a/src/pas/commands/builtins/common.rs b/src/pas/commands/builtins/common.rs deleted file mode 100644 index 44e51b6..0000000 --- a/src/pas/commands/builtins/common.rs +++ /dev/null @@ -1,33 +0,0 @@ -use std::path::{Path, PathBuf}; -use crate::pas::context::ShellContext; -use std::fs; -use anyhow::Result; - -pub fn resolve_path(ctx: &ShellContext, path: &str) -> PathBuf { - let p = Path::new(path); - if p.is_absolute() { - p.to_path_buf() - } else { - ctx.cwd.join(p) - } -} - -pub fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { - if !dst.exists() { - fs::create_dir_all(dst)?; - } - - for entry in fs::read_dir(src)? { - let entry = entry?; - let ty = entry.file_type()?; - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - - if ty.is_dir() { - copy_dir_recursive(&src_path, &dst_path)?; - } else { - fs::copy(&src_path, &dst_path)?; - } - } - Ok(()) -} \ No newline at end of file diff --git a/src/pas/commands/builtins/env/cd.rs b/src/pas/commands/builtins/env/cd.rs deleted file mode 100644 index 84a69fa..0000000 --- a/src/pas/commands/builtins/env/cd.rs +++ /dev/null @@ -1,33 +0,0 @@ -// Cd command - -use crate::pas::commands::Executable; -use crate::pas::context::ShellContext; -use anyhow::{Result, bail}; -use std::io::{Read, Write}; -use crate::pas::commands::builtins::common::resolve_path; - -pub struct CdCommand; -impl Executable for CdCommand { - fn execute(&self, args: &[String], ctx: &mut ShellContext, _stdin: Option>, _stdout: Option>, _stderr: Option>) -> Result { - // args[0] is "cd". args[1] is path. - let path_str = if args.len() < 2 { - // Default to HOME or root? - ctx.env.get("HOME").map(|s| s.as_str()).unwrap_or("/") - } else { - &args[1] - }; - - let new_path = resolve_path(ctx, path_str); - if new_path.exists() && new_path.is_dir() { - // Canonicalize to remove .. and . - if let Ok(canon) = new_path.canonicalize() { - ctx.cwd = canon; - } else { - ctx.cwd = new_path; // Fallback - } - Ok(0) - } else { - bail!("cd: no such file or directory: {}", path_str); - } - } -} \ No newline at end of file diff --git a/src/pas/commands/builtins/env/exit.rs b/src/pas/commands/builtins/env/exit.rs deleted file mode 100644 index d9a512f..0000000 --- a/src/pas/commands/builtins/env/exit.rs +++ /dev/null @@ -1,25 +0,0 @@ -// Exit command - -use crate::pas::commands::Executable; -use crate::pas::context::ShellContext; -use anyhow::Result; -use std::io::{Read, Write}; - -pub struct ExitCommand; -impl Executable for ExitCommand { - fn execute( - &self, - args: &[String], - _ctx: &mut ShellContext, - _stdin: Option>, - mut _stdout: Option>, - _stderr: Option>, - ) -> Result { - let exit_code = if args.len() > 1 { - args[1].parse::().unwrap_or(0) - } else { - 0 - }; - std::process::exit(exit_code); - } -} \ No newline at end of file diff --git a/src/pas/commands/builtins/env/export.rs b/src/pas/commands/builtins/env/export.rs deleted file mode 100644 index 37cdd0b..0000000 --- a/src/pas/commands/builtins/env/export.rs +++ /dev/null @@ -1,27 +0,0 @@ -use crate::pas::commands::Executable; -use crate::pas::context::ShellContext; -use anyhow::Result; -use std::io::{Read, Write}; - -pub struct ExportCommand; - -impl Executable for ExportCommand { - fn execute( - &self, - args: &[String], - ctx: &mut ShellContext, - _stdin: Option>, - _stdout: Option>, - _stderr: Option>, - ) -> Result { - // args[0] is "export" - for arg in &args[1..] { - if let Some(idx) = arg.find('=') { - let key = arg[..idx].to_string(); - let value = arg[idx+1..].to_string(); - ctx.env.insert(key, value); - } - } - Ok(0) - } -} diff --git a/src/pas/commands/builtins/env/mod.rs b/src/pas/commands/builtins/env/mod.rs deleted file mode 100644 index bbaff23..0000000 --- a/src/pas/commands/builtins/env/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod cd; -pub mod exit; -pub mod export; -pub mod source; diff --git a/src/pas/commands/builtins/env/source.rs b/src/pas/commands/builtins/env/source.rs deleted file mode 100644 index b715f18..0000000 --- a/src/pas/commands/builtins/env/source.rs +++ /dev/null @@ -1,43 +0,0 @@ -use crate::pas::commands::Executable; -use crate::pas::context::ShellContext; -use anyhow::{Result, Context}; -use std::io::{Read, Write}; -use std::fs; -use crate::pas::parser::parse_command_line; -use crate::pas::executor::execute_expr; - -pub struct SourceCommand; - -impl Executable for SourceCommand { - fn execute( - &self, - args: &[String], - ctx: &mut ShellContext, - stdin: Option>, - stdout: Option>, - stderr: Option>, - ) -> Result { - if args.len() < 2 { - // No file specified - return Ok(1); - } - let filepath = &args[1]; - let content = fs::read_to_string(filepath) - .with_context(|| format!("Failed to read file: {}", filepath))?; - - match parse_command_line(&content, ctx) { - Ok(expr) => { - execute_expr(expr, ctx, stdin, stdout, stderr) - }, - Err(e) => { - // We should probably log this properly - if let Some(mut err) = stderr { - writeln!(err, "Source error: {}", e).ok(); - } else { - eprintln!("Source error: {}", e); - } - Ok(1) - } - } - } -} diff --git a/src/pas/commands/builtins/fs/cp.rs b/src/pas/commands/builtins/fs/cp.rs deleted file mode 100644 index 0867775..0000000 --- a/src/pas/commands/builtins/fs/cp.rs +++ /dev/null @@ -1,69 +0,0 @@ -// Cp command - -use crate::pas::commands::Executable; -use crate::pas::context::ShellContext; -use anyhow::{Result, Context, bail}; -use std::fs; -use std::io::{Read, Write}; -use crate::pas::commands::builtins::common::{resolve_path, copy_dir_recursive}; -use super::check_path_access; - -pub struct CpCommand; -impl Executable for CpCommand { - fn execute(&self, args: &[String], ctx: &mut ShellContext, _stdin: Option>, _stdout: Option>, _stderr: Option>) -> Result { - let mut recursive = false; - let mut paths = Vec::new(); - - // Skip command name - for arg in args.iter().skip(1) { - if arg == "-r" || arg == "-R" || arg == "--recursive" { - recursive = true; - } else { - paths.push(arg); - } - } - - if paths.len() < 2 { - bail!("cp requires at least source and destination"); - } - - let dest_str = paths.pop().unwrap(); - let sources = paths; - - let dest_path = resolve_path(ctx, &dest_str); - check_path_access(&dest_path, ctx)?; - - let dest_is_dir = dest_path.is_dir(); - - if sources.len() > 1 && !dest_is_dir { - bail!("Target '{}' is not a directory", dest_str); - } - - for src_str in sources { - let src_path = resolve_path(ctx, src_str); - check_path_access(&src_path, ctx)?; - - if !src_path.exists() { - bail!("Source not found: {}", src_str); - } - - let target = if dest_is_dir { - dest_path.join(src_path.file_name().ok_or_else(|| anyhow::anyhow!("Invalid source filename"))?) - } else { - dest_path.clone() - }; - - if src_path.is_dir() { - if recursive { - copy_dir_recursive(&src_path, &target)?; - } else { - bail!("Omitting directory '{}' (use -r to copy)", src_str); - } - } else { - fs::copy(&src_path, &target).with_context(|| format!("Failed to copy {} to {}", src_str, target.display()))?; - } - } - - Ok(0) - } -} \ No newline at end of file diff --git a/src/pas/commands/builtins/fs/ls.rs b/src/pas/commands/builtins/fs/ls.rs deleted file mode 100644 index 4a3a251..0000000 --- a/src/pas/commands/builtins/fs/ls.rs +++ /dev/null @@ -1,45 +0,0 @@ -// Ls command - -use std::fs; -use std::io::{Read, Write}; -use crate::pas::commands::Executable; -use crate::pas::context::ShellContext; -use anyhow::{Result, Context}; -use crate::pas::commands::builtins::common::resolve_path; - -pub struct LsCommand; -impl Executable for LsCommand { - fn execute( - &self, - args: &[String], - ctx: &mut ShellContext, - _stdin: Option>, - stdout: Option>, - _stderr: Option>, - ) -> Result { - let path_str = if args.len() > 1 { - &args[1] - } else { - "." - }; - - let path = resolve_path(ctx, path_str); - let entries = fs::read_dir(&path) - .with_context(|| format!("Failed to read directory: {}", path_str))?; - - let mut output = String::new(); - for entry in entries { - let entry = entry?; - let file_name = entry.file_name(); - output.push_str(&format!("{}\n", file_name.to_string_lossy())); - } - - if let Some(mut out) = stdout { - write!(out, "{}", output)?; - } else { - print!("{}", output); - } - - Ok(0) - } -} \ No newline at end of file diff --git a/src/pas/commands/builtins/fs/mkdir.rs b/src/pas/commands/builtins/fs/mkdir.rs deleted file mode 100644 index e777f0b..0000000 --- a/src/pas/commands/builtins/fs/mkdir.rs +++ /dev/null @@ -1,39 +0,0 @@ -// Mkdir command - -use crate::pas::commands::Executable; -use crate::pas::context::ShellContext; -use anyhow::{Result, Context}; -use std::fs; -use std::io::{Read, Write}; -use crate::pas::commands::builtins::common::resolve_path; -use super::check_path_access; - -pub struct MkdirCommand; -impl Executable for MkdirCommand { - fn execute(&self, args: &[String], ctx: &mut ShellContext, _stdin: Option>, _stdout: Option>, _stderr: Option>) -> Result { - let mut parents = false; - let mut paths = Vec::new(); - - // Skip command name - for arg in args.iter().skip(1) { - if arg == "-p" { - parents = true; - } else if arg.starts_with('-') { - // Ignore other flags - } else { - paths.push(arg); - } - } - - for path_str in paths { - let p = resolve_path(ctx, path_str); - check_path_access(&p, ctx)?; - if parents { - fs::create_dir_all(&p).with_context(|| format!("Failed to create directory (with parents): {}", path_str))?; - } else { - fs::create_dir(&p).with_context(|| format!("Failed to create directory: {}", path_str))?; - } - } - Ok(0) - } -} \ No newline at end of file diff --git a/src/pas/commands/builtins/fs/mod.rs b/src/pas/commands/builtins/fs/mod.rs deleted file mode 100644 index d818c36..0000000 --- a/src/pas/commands/builtins/fs/mod.rs +++ /dev/null @@ -1,67 +0,0 @@ -use crate::pas::context::ShellContext; -use anyhow::{Result, bail}; -use std::path::Path; -use std::fs; - -pub mod cp; -pub mod mkdir; -pub mod rm; -pub mod ls; -pub mod mv; - -pub fn check_path_access(target: &Path, ctx: &ShellContext) -> Result<()> { - if let Some(caps) = &ctx.capabilities { - if let Some(allowed_strs) = &caps.allow_paths { - // 1. Resolve target to absolute path to remove ambiguity - let abs_target = if target.is_absolute() { - target.to_path_buf() - } else { - ctx.cwd.join(target) - }; - - // 2. Canonicalize target (handle .. symlinks) - // If target does not exist, try to canonicalize parent. - let canonical_target = match fs::canonicalize(&abs_target) { - Ok(p) => p, - Err(_) => { - // If path doesn't exist, try parent - if let Some(parent) = abs_target.parent() { - if let Ok(canon_parent) = fs::canonicalize(parent) { - canon_parent.join(abs_target.file_name().unwrap_or_default()) - } else { - abs_target.clone() - } - } else { - abs_target.clone() - } - } - }; - - // 3. Check against allowed paths - // NOTE: We assume allowed_strs are either absolute or we match them as prefix components. - // If allow_paths = ["src"], and we access "/abs/to/proj/src/file", it won't match "src". - // We really need resolved allowed paths. - // Ideally, Config loading should resolve these relative to project root. - // For now, we attempt to match strictly. - - let mut denied = true; - for allowed in allowed_strs { - let allowed_path = Path::new(allowed); - // Check if canonical_target starts with allowed_path - if canonical_target.starts_with(allowed_path) { - denied = false; - break; - } - - // If allowed is relative, and we are running inside the directory... - // This is tricky without knowing project root. - // We'll fallback to simple string/path matching. - } - - if denied { - bail!("🚫 Security: Access to '{}' is denied by allow_paths.", target.display()); - } - } - } - Ok(()) -} diff --git a/src/pas/commands/builtins/fs/mv.rs b/src/pas/commands/builtins/fs/mv.rs deleted file mode 100644 index 847248a..0000000 --- a/src/pas/commands/builtins/fs/mv.rs +++ /dev/null @@ -1,60 +0,0 @@ -// Mv command - -use crate::pas::commands::Executable; -use crate::pas::context::ShellContext; -use anyhow::{Result, Context, bail}; -use std::fs; -use std::io::Write; -use std::path::Path; -use super::check_path_access; - -pub struct MvCommand; -impl Executable for MvCommand { - fn execute( - &self, - args: &[String], - ctx: &mut ShellContext, - _stdin: Option>, - _stdout: Option>, - stderr: Option>, - ) -> Result { - if args.len() < 3 { - if let Some(mut err) = stderr { - writeln!(err, "Usage: mv ... ")?; - } else { - writeln!(std::io::stderr(), "Usage: mv ... ")?; - } - return Ok(1); - } - - let dest = args.last().unwrap(); - let dest_path = Path::new(dest); - check_path_access(dest_path, ctx)?; - - let dest_is_dir = dest_path.is_dir(); - - let sources = &args[1..args.len() - 1]; - if sources.len() > 1 && !dest_is_dir { - bail!("Target '{}' is not a directory", dest); - } - - for src in sources { - let src_path = Path::new(src); - check_path_access(src_path, ctx)?; - - if !src_path.exists() { - bail!("Source not found: {}", src); - } - - let target = if dest_is_dir { - dest_path.join(src_path.file_name().ok_or_else(|| anyhow::anyhow!("Invalid source filename"))?) - } else { - dest_path.to_path_buf() - }; - - fs::rename(src_path, &target).with_context(|| format!("Failed to move {} to {}", src, target.display()))?; - } - - Ok(0) - } -} \ No newline at end of file diff --git a/src/pas/commands/builtins/fs/rm.rs b/src/pas/commands/builtins/fs/rm.rs deleted file mode 100644 index 9e45ebe..0000000 --- a/src/pas/commands/builtins/fs/rm.rs +++ /dev/null @@ -1,50 +0,0 @@ -// Rm command - -use crate::pas::commands::Executable; -use crate::pas::context::ShellContext; -use anyhow::{Result, Context, bail}; -use std::fs; -use std::io::{Read, Write}; -use crate::pas::commands::builtins::common::resolve_path; -use super::check_path_access; - -pub struct RmCommand; -impl Executable for RmCommand { - fn execute(&self, args: &[String], ctx: &mut ShellContext, _stdin: Option>, _stdout: Option>, _stderr: Option>) -> Result { - let mut recursive = false; - let mut force = false; - let mut paths = Vec::new(); - - // Skip command name (args[0]) - for arg in args.iter().skip(1) { - if arg.starts_with('-') { - if arg.contains('r') || arg.contains('R') { recursive = true; } - if arg.contains('f') { force = true; } - } else { - paths.push(arg); - } - } - - for path_str in paths { - let p = resolve_path(ctx, path_str); - check_path_access(&p, ctx)?; - if !p.exists() { - if !force { - bail!("File not found: {}", path_str); - } - continue; - } - - if p.is_dir() { - if recursive { - fs::remove_dir_all(&p).with_context(|| format!("Failed to remove directory: {}", path_str))?; - } else { - bail!("Cannot remove directory '{}' without -r", path_str); - } - } else { - fs::remove_file(&p).with_context(|| format!("Failed to remove file: {}", path_str))?; - } - } - Ok(0) - } -} \ No newline at end of file diff --git a/src/pas/commands/builtins/io/cat.rs b/src/pas/commands/builtins/io/cat.rs deleted file mode 100644 index 9c29f09..0000000 --- a/src/pas/commands/builtins/io/cat.rs +++ /dev/null @@ -1,39 +0,0 @@ -// Cat command - -use crate::pas::commands::Executable; -use crate::pas::context::ShellContext; -use anyhow::Result; -use std::fs::File; -use std::io::{Read, Write, BufReader}; - -pub struct CatCommand; -impl Executable for CatCommand { - fn execute( - &self, - args: &[String], - _ctx: &mut ShellContext, - _stdin: Option>, - stdout: Option>, - _stderr: Option>, - ) -> Result { - let mut out: Box = match stdout { - Some(s) => s, - None => Box::new(std::io::stdout()), - }; - - if args.len() < 2 { - writeln!(out, "Usage: cat ...")?; - return Ok(1); - } - - for filename in &args[1..] { - let file = File::open(filename)?; - let mut reader = BufReader::new(file); - let mut buffer = Vec::new(); - reader.read_to_end(&mut buffer)?; - out.write_all(&buffer)?; - } - - Ok(0) - } -} \ No newline at end of file diff --git a/src/pas/commands/builtins/io/echo.rs b/src/pas/commands/builtins/io/echo.rs deleted file mode 100644 index 3cf2417..0000000 --- a/src/pas/commands/builtins/io/echo.rs +++ /dev/null @@ -1,30 +0,0 @@ -// Echo command - -use crate::pas::commands::Executable; -use crate::pas::context::ShellContext; -use anyhow::Result; -use std::io::{Read, Write}; - -pub struct EchoCommand; - -impl Executable for EchoCommand { - fn execute( - &self, - args: &[String], - _ctx: &mut ShellContext, - _stdin: Option>, - stdout: Option>, - _stderr: Option>, - ) -> Result { - // Skip "echo" in args[0] - let output = args[1..].join(" "); - - if let Some(mut out) = stdout { - writeln!(out, "{}", output)?; - } else { - println!("{}", output); - } - - Ok(0) - } -} \ No newline at end of file diff --git a/src/pas/commands/builtins/io/mod.rs b/src/pas/commands/builtins/io/mod.rs deleted file mode 100644 index 3870dee..0000000 --- a/src/pas/commands/builtins/io/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod echo; -pub mod cat; \ No newline at end of file diff --git a/src/pas/commands/builtins/mod.rs b/src/pas/commands/builtins/mod.rs deleted file mode 100644 index 3227652..0000000 --- a/src/pas/commands/builtins/mod.rs +++ /dev/null @@ -1,39 +0,0 @@ -pub mod fs; -pub mod env; -pub mod io; -pub mod common; // Private helpers - -use crate::pas::context::ShellContext; - -/* Commands note with '//' means they are too simple or too popular/classic - to need portable version (p:). So only one version is registered. - Specifically, 'echo' is only registered as normal command, not p:echo, - because it's the built-in command that every shell has. -*/ - -/// Helper to register all built-in commands at once -pub fn register_all_builtins(ctx: &mut ShellContext) { - // FS commands - ctx.register_command("rm", Box::new(fs::rm::RmCommand)); - ctx.register_command("p:rm", Box::new(fs::rm::RmCommand)); - ctx.register_command("mkdir", Box::new(fs::mkdir::MkdirCommand)); - ctx.register_command("p:mkdir", Box::new(fs::mkdir::MkdirCommand)); - ctx.register_command("cp", Box::new(fs::cp::CpCommand)); - ctx.register_command("p:cp", Box::new(fs::cp::CpCommand)); - ctx.register_command("ls", Box::new(fs::ls::LsCommand)); - ctx.register_command("p:ls", Box::new(fs::ls::LsCommand)); - ctx.register_command("mv", Box::new(fs::mv::MvCommand)); - ctx.register_command("p:mv", Box::new(fs::mv::MvCommand)); - - // Env/Navigation - ctx.register_command("cd", Box::new(env::cd::CdCommand)); // `cd` in CMD (without args) is like `pwd`? I'll look into it later - ctx.register_command("exit", Box::new(env::exit::ExitCommand)); // - ctx.register_command("export", Box::new(env::export::ExportCommand)); - ctx.register_command("source", Box::new(env::source::SourceCommand)); - ctx.register_command(".", Box::new(env::source::SourceCommand)); - - // IO - ctx.register_command("echo", Box::new(io::echo::EchoCommand)); // - ctx.register_command("cat", Box::new(io::cat::CatCommand)); - ctx.register_command("p:cat", Box::new(io::cat::CatCommand)); -} \ No newline at end of file diff --git a/src/pas/commands/mod.rs b/src/pas/commands/mod.rs deleted file mode 100644 index c594734..0000000 --- a/src/pas/commands/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -pub mod builtins; -pub mod system; -pub mod adapter; - -use crate::pas::context::ShellContext; -use anyhow::Result; -use std::io::{Read, Write}; - -pub trait Executable: Send + Sync { - fn execute( - &self, - args: &[String], - ctx: &mut ShellContext, - stdin: Option>, - stdout: Option>, - stderr: Option>, - ) -> Result; -} diff --git a/src/pas/commands/system.rs b/src/pas/commands/system.rs deleted file mode 100644 index d8b6c82..0000000 --- a/src/pas/commands/system.rs +++ /dev/null @@ -1,123 +0,0 @@ -// System command -use crate::pas::commands::Executable; -use crate::pas::context::ShellContext; -use anyhow::{Result, Context}; -use std::process::{Command, Stdio}; -use std::io::{Read, Write}; -use std::thread; - -pub struct SystemCommand; - -impl Executable for SystemCommand { - fn execute( - &self, - args: &[String], - ctx: &mut ShellContext, - stdin: Option>, - stdout: Option>, - stderr: Option> - ) -> Result { - if args.is_empty() { - return Ok(0); - } - let program = &args[0]; - - // Security Check: allow_exec - if let Some(caps) = &ctx.capabilities { - if let Some(allowed) = &caps.allow_exec { - if !allowed.iter().any(|a| a == program) { - use colored::*; - let err_msg = format!("🚫 Security: Execution of '{}' is not allowed by configuration.", program); - if let Some(mut e) = stderr { - writeln!(e, "{}", err_msg.red())?; - } else { - eprintln!("{}", err_msg.red()); - } - return Ok(126); - } - } - } - - let cmd_args = &args[1..]; - - let mut cmd = Command::new(program); - cmd.current_dir(&ctx.cwd); - - // Shadowing: we use the context's environment as the source of truth - cmd.env_clear(); - cmd.envs(&ctx.env); - - cmd.args(cmd_args); - - // Handle Stdin - if stdin.is_some() { - cmd.stdin(Stdio::piped()); - } else { - cmd.stdin(Stdio::inherit()); - } - - // Handle Stdout - if stdout.is_some() { - cmd.stdout(Stdio::piped()); - } else { - cmd.stdout(Stdio::inherit()); - } - - // Handle Stderr - if stderr.is_some() { - cmd.stderr(Stdio::piped()); - } else { - cmd.stderr(Stdio::inherit()); - } - - let mut child = cmd.spawn().with_context(|| format!("Failed to execute command: {}", program))?; - - // Spawn thread for Stdin - if let Some(mut source) = stdin { - if let Some(mut child_in) = child.stdin.take() { - thread::spawn(move || { - std::io::copy(&mut source, &mut child_in).ok(); - }); - } - } - - // Spawn thread for Stdout - let stdout_thread = if let Some(mut dest) = stdout { - if let Some(mut child_out) = child.stdout.take() { - Some(thread::spawn(move || { - std::io::copy(&mut child_out, &mut dest).ok(); - })) - } else { - None - } - } else { - None - }; - - // Spawn thread for Stderr - let stderr_thread = if let Some(mut dest) = stderr { - if let Some(mut child_err) = child.stderr.take() { - Some(thread::spawn(move || { - std::io::copy(&mut child_err, &mut dest).ok(); - })) - } else { - None - } - } else { - None - }; - - let status = child.wait()?; - - // Wait for stdout thread to finish copying (ensure all output is flushed) - if let Some(handle) = stdout_thread { - handle.join().ok(); - } - - if let Some(handle) = stderr_thread { - handle.join().ok(); - } - - Ok(status.code().unwrap_or(1)) - } -} diff --git a/src/pas/context.rs b/src/pas/context.rs deleted file mode 100644 index e7663d4..0000000 --- a/src/pas/context.rs +++ /dev/null @@ -1,49 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use crate::pas::commands::Executable; -use crate::config::CapabilityConfig; - -#[derive(Clone)] -pub struct ShellContext { - pub cwd: PathBuf, - pub env: HashMap, - pub exit_code: i32, - pub registry: Arc>>, - pub capabilities: Option, -} - -impl ShellContext { - pub fn new(capabilities: Option) -> Self { - let env: HashMap = std::env::vars().collect(); - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let mut ctx = Self { - cwd, - env, - exit_code: 0, - registry: Arc::new(HashMap::new()), - capabilities, - }; - crate::pas::commands::builtins::register_all_builtins(&mut ctx); - ctx - } - - pub fn register_command(&mut self, name: &str, command: Box) { - if let Some(map) = Arc::get_mut(&mut self.registry) { - map.insert(name.to_string(), command); - } else { - // This should not happen during initialization phase - panic!("Cannot register command: Registry is shared"); - } - } - - pub fn clone_for_parallel(&self) -> Self { - Self { - cwd: self.cwd.clone(), - env: self.env.clone(), - exit_code: self.exit_code, - registry: self.registry.clone(), - capabilities: self.capabilities.clone(), - } - } -} diff --git a/src/pas/executor.rs b/src/pas/executor.rs deleted file mode 100644 index 054de92..0000000 --- a/src/pas/executor.rs +++ /dev/null @@ -1,294 +0,0 @@ -use crate::pas::ast::{CommandExpr, RedirectMode, Arg, ArgPart}; -use crate::pas::context::ShellContext; -use crate::pas::commands::system::SystemCommand; -use crate::pas::commands::Executable; -use anyhow::{Result, Context}; -use std::io::{Read, Write}; -use std::fs::OpenOptions; -use std::thread; -use os_pipe::pipe; -use std::path::MAIN_SEPARATOR; -use std::sync::{Arc, Mutex}; - -// SharedWriter allows cloning a writer handle (by sharing the underlying writer via Arc+Mutex) -#[derive(Clone)] -struct SharedWriter(Arc>>); - -impl SharedWriter { - fn new(w: Box) -> Self { - Self(Arc::new(Mutex::new(w))) - } -} - -impl Write for SharedWriter { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.0.lock().unwrap().write(buf) - } - fn flush(&mut self) -> std::io::Result<()> { - self.0.lock().unwrap().flush() - } -} - -pub fn execute_expr( - expr: CommandExpr, - ctx: &mut ShellContext, - stdin: Option>, - stdout: Option>, - stderr: Option>, -) -> Result { - // Wrap stderr if present to allow sharing across sequence/logic branches - let stderr_shared = stderr.map(SharedWriter::new); - - let get_stderr = || -> Option> { - stderr_shared.as_ref().map(|s| Box::new(s.clone()) as Box) - }; - - match expr { - CommandExpr::Simple { program, args } => { - let prog_str = expand_arg(&program, ctx); - let mut full_args = vec![prog_str.clone()]; - - for arg in args { - let arg_str = expand_arg(&arg, ctx); - let has_wildcard = arg_str.contains('*') || arg_str.contains('?') || arg_str.contains('['); - if has_wildcard { - let mut found = false; - if let Ok(paths) = glob::glob(&arg_str) { - for entry in paths { - if let Ok(path) = entry { - full_args.push(path.to_string_lossy().into_owned()); - found = true; - } - } - } - if !found { - full_args.push(arg_str); - } - } else { - full_args.push(arg_str); - } - } - - let registry = ctx.registry.clone(); - let exit_code = if let Some(cmd) = registry.get(&prog_str) { - cmd.execute(&full_args, ctx, stdin, stdout, get_stderr())? - } else { - let sys_cmd = SystemCommand; - sys_cmd.execute(&full_args, ctx, stdin, stdout, get_stderr())? - }; - - ctx.exit_code = exit_code; - Ok(exit_code) - }, - CommandExpr::Pipe { left, right } => { - let (reader, writer) = pipe().context("Failed to create pipe")?; - let mut ctx_left = ctx.clone_for_parallel(); - let err_left = get_stderr(); - let left_thread = thread::spawn(move || { - execute_expr(*left, &mut ctx_left, stdin, Some(Box::new(writer)), err_left) - }); - let right_res = execute_expr(*right, ctx, Some(Box::new(reader)), stdout, get_stderr()); - let _ = left_thread.join().unwrap(); - right_res - }, - CommandExpr::Redirect { cmd, target, mode, source_fd } => { - if let RedirectMode::MergeStderrToStdout = mode { - // 2>&1 case. Redirect stderr to where stdout is going. - if let Some(out) = stdout { - let shared = SharedWriter::new(out); - let out_clone = Box::new(shared.clone()); - let err_clone = Box::new(shared.clone()); - // If we are redirecting 2>&1, we ignore the current stderr (get_stderr result) - // and replace it with stdout's handle. - // But wait, what if we have `3>&1`? We only support 2>&1 via MergeStderrToStdout variant implies. - execute_expr(*cmd, ctx, stdin, Some(out_clone), Some(err_clone)) - } else { - // If no stdout is captured, inherit both? - // Or force both to inherit. - execute_expr(*cmd, ctx, stdin, None, None) - } - } else { - let target_str = expand_arg(&target, ctx); - let mut open_opts = OpenOptions::new(); - match mode { - RedirectMode::Overwrite => { open_opts.write(true).create(true).truncate(true); }, - RedirectMode::Append => { open_opts.write(true).create(true).append(true); }, - RedirectMode::Input => { open_opts.read(true); }, - _ => unreachable!(), - }; - let file = open_opts.open(&target_str).with_context(|| format!("Failed to open file: {}", target_str))?; - - if mode == RedirectMode::Input { - execute_expr(*cmd, ctx, Some(Box::new(file)), stdout, get_stderr()) - } else { - // Output redirection - let file_box = Box::new(file); - if source_fd == 2 { - execute_expr(*cmd, ctx, stdin, stdout, Some(file_box)) - } else { - // Default to stdout (1) - execute_expr(*cmd, ctx, stdin, Some(file_box), get_stderr()) - } - } - } - }, - CommandExpr::And(left, right) => { - handle_sequence(*left, Some(*right), ctx, stdin, stdout, get_stderr(), SequenceMode::And) - }, - CommandExpr::Or(left, right) => { - handle_sequence(*left, Some(*right), ctx, stdin, stdout, get_stderr(), SequenceMode::Or) - }, - CommandExpr::Sequence(left, right) => { - handle_sequence(*left, Some(*right), ctx, stdin, stdout, get_stderr(), SequenceMode::Always) - }, - CommandExpr::Assignment { key, value } => { - let val_str = expand_arg(&value, ctx); - ctx.env.insert(key, val_str); - Ok(0) - }, - CommandExpr::Subshell(cmd) => { - let mut sub_ctx = ctx.clone_for_parallel(); - execute_expr(*cmd, &mut sub_ctx, stdin, stdout, get_stderr()) - }, - CommandExpr::If { cond, then_branch, else_branch } => { - let cond_res = execute_expr(*cond, ctx, stdin, None, get_stderr())?; - if cond_res == 0 { - execute_expr(*then_branch, ctx, None, stdout, get_stderr()) - } else if let Some(else_block) = else_branch { - execute_expr(*else_block, ctx, None, stdout, get_stderr()) - } else { - Ok(0) - } - }, - CommandExpr::While { cond, body } => { - loop { - // We clone the Box. - let cond_val = *cond.clone(); - let res = execute_expr(cond_val, ctx, None, None, get_stderr())?; - if res == 0 { - let body_val = *body.clone(); - execute_expr(body_val, ctx, None, None, get_stderr())?; - } else { - break; - } - } - Ok(0) - } - } -} - -fn expand_arg(arg: &Arg, ctx: &ShellContext) -> String { - let mut res = String::new(); - let mut iter = arg.0.iter(); - - if let Some(first) = iter.next() { - match first { - ArgPart::Literal(s) => { - if s == "~" || s.starts_with("~/") { - if let Some(home) = ctx.env.get("HOME") { - res.push_str(home); - res.push_str(&s[1..]); - } else { - res.push_str(s); - } - } else { - res.push_str(s); - } - } - ArgPart::Variable(name) => { - if name == "?" { - res.push_str(&ctx.exit_code.to_string()); - } else if let Some(val) = ctx.env.get(name) { - res.push_str(val); - } - } - } - } - - for part in iter { - match part { - ArgPart::Literal(s) => res.push_str(s), - ArgPart::Variable(name) => { - if name == "?" { - res.push_str(&ctx.exit_code.to_string()); - } else if let Some(val) = ctx.env.get(name) { - res.push_str(val); - } - } - } - } - // Windows normalization? - if cfg!(windows) && res.contains('/') { - res = res.replace('/', &MAIN_SEPARATOR.to_string()); - } - res -} - -#[derive(PartialEq)] -enum SequenceMode { - And, - Or, - Always, -} - -fn handle_sequence( - left: CommandExpr, - right: Option, - ctx: &mut ShellContext, - stdin: Option>, - stdout: Option>, - stderr: Option>, - mode: SequenceMode -) -> Result { - let stderr_shared = stderr.map(SharedWriter::new); - let get_stderr = || -> Option> { - stderr_shared.as_ref().map(|s| Box::new(s.clone()) as Box) - }; - - if let Some(out) = stdout { - let (mut reader, writer) = pipe().context("Failed to create bridge pipe")?; - let mut out_sink = out; - let bridge_thread = thread::spawn(move || { - std::io::copy(&mut reader, &mut out_sink).ok(); - }); - let w1 = writer.try_clone().context("Failed to clone pipe writer")?; - let w2 = writer; - - let left_res = execute_expr(left, ctx, stdin, Some(Box::new(w1)), get_stderr())?; - - let proceed = match mode { - SequenceMode::And => left_res == 0, - SequenceMode::Or => left_res != 0, - SequenceMode::Always => true, - }; - - let final_res = if proceed { - if let Some(r) = right { - execute_expr(r, ctx, None, Some(Box::new(w2)), get_stderr())? - } else { - left_res - } - } else { - drop(w2); - left_res - }; - bridge_thread.join().unwrap(); - Ok(final_res) - } else { - let left_res = execute_expr(left, ctx, stdin, None, get_stderr())?; - let proceed = match mode { - SequenceMode::And => left_res == 0, - SequenceMode::Or => left_res != 0, - SequenceMode::Always => true, - }; - if proceed { - if let Some(r) = right { - execute_expr(r, ctx, None, None, get_stderr()) - } else { - Ok(left_res) - } - } else { - Ok(left_res) - } - } -} diff --git a/src/pas/mod.rs b/src/pas/mod.rs deleted file mode 100644 index adcf394..0000000 --- a/src/pas/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -pub mod context; -pub mod commands; -pub mod parser; -pub mod ast; -pub mod executor; - -use context::ShellContext; -use executor::execute_expr; -use anyhow::Result; -use std::io::Write; - -#[cfg(test)] -mod tests; - -pub fn run_command_line(cmd_str: &str, ctx: &mut ShellContext, stdout: Option>, stderr: Option>) -> Result { - let expr = parser::parse_command_line(cmd_str, ctx)?; - execute_expr(expr, ctx, None, stdout, stderr) -} diff --git a/src/pas/parser.rs b/src/pas/parser.rs deleted file mode 100644 index 4e32ab1..0000000 --- a/src/pas/parser.rs +++ /dev/null @@ -1,357 +0,0 @@ -use nom::{ - branch::alt, - bytes::complete::{is_not, tag, take_while, take_while1}, - character::complete::{char, multispace0, multispace1, satisfy, digit1, one_of}, - combinator::{map, peek, opt, cut}, - multi::{many0, many1, fold_many0, separated_list0}, - sequence::{delimited, pair, preceded}, - IResult, -}; -use nom::error::Error; -use crate::pas::ast::{CommandExpr, RedirectMode, Arg, ArgPart}; -use crate::pas::context::ShellContext; - -struct ParserContext<'a> { - _ctx: &'a ShellContext, -} - -pub fn parse_command_line(input: &str, ctx: &ShellContext) -> anyhow::Result { - let pctx = ParserContext { _ctx: ctx }; - match parse_sequence(input, &pctx) { - Ok((rem, expr)) => { - let (rem, _) = multispace0::<&str, nom::error::Error<&str>>(rem).unwrap_or((rem, "")); - if !rem.is_empty() { - anyhow::bail!("Unexpected input remaining: {}", rem); - } - Ok(expr) - }, - Err(e) => anyhow::bail!("Parse error: {}", e), - } -} - -// 0. Sequence: ; -fn parse_sequence<'a>(input: &'a str, pctx: &ParserContext) -> IResult<&'a str, CommandExpr> { - let (input, list) = separated_list0( - delimited(multispace0, char(';'), multispace0), - |i| parse_logic(i, pctx) - )(input)?; - - if list.is_empty() { - return Err(nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Verify))); - } - - let mut iter = list.into_iter(); - let first = iter.next().unwrap(); - - let res = iter.fold(first, |acc, next| { - CommandExpr::Sequence(Box::new(acc), Box::new(next)) - }); - - Ok((input, res)) -} - -// 1. Logic: &&, || -fn parse_logic<'a>(input: &'a str, pctx: &ParserContext) -> IResult<&'a str, CommandExpr> { - let (input, init) = parse_pipe(input, pctx)?; - - fold_many0( - pair( - delimited(multispace0, alt((tag("&&"), tag("||"))), multispace0), - |i| parse_pipe(i, pctx) - ), - move || init.clone(), - |acc, (op, next)| { - match op { - "&&" => CommandExpr::And(Box::new(acc), Box::new(next)), - "||" => CommandExpr::Or(Box::new(acc), Box::new(next)), - _ => unreachable!(), - } - } - )(input) -} - -// 2. Pipe: | -fn parse_pipe<'a>(input: &'a str, pctx: &ParserContext) -> IResult<&'a str, CommandExpr> { - let (input, init) = parse_redirect(input, pctx)?; - - fold_many0( - preceded( - delimited(multispace0, char('|'), multispace0), - |i| parse_redirect(i, pctx) - ), - move || init.clone(), - |acc, next| { - CommandExpr::Pipe { - left: Box::new(acc), - right: Box::new(next), - } - } - )(input) -} - -// 3. Redirect: >, >>, <, 2>&1 -fn parse_redirect<'a>(input: &'a str, pctx: &ParserContext) -> IResult<&'a str, CommandExpr> { - let (input, cmd) = parse_atomic(input, pctx)?; - - let (input, redirects) = many0(|i| parse_redirect_entry(i, pctx))(input)?; - - let res = redirects.into_iter().rev().fold(cmd, |acc, (mode, target, source_fd)| { - CommandExpr::Redirect { - cmd: Box::new(acc), - target, - mode, - source_fd, - } - }); - - Ok((input, res)) -} - -fn parse_redirect_entry<'a>(input: &'a str, pctx: &ParserContext) -> IResult<&'a str, (RedirectMode, Arg, i32)> { - let (input, _) = multispace0(input)?; - let (input, fd_str) = opt(digit1)(input)?; - let source_fd = fd_str.map(|s: &str| s.parse::().unwrap()).unwrap_or(-1); - - alt(( - // 2>&1 - map(preceded(tag(">&"), cut(digit1)), move |target_fd: &str| { - let src = if source_fd == -1 { 1 } else { source_fd }; - (RedirectMode::MergeStderrToStdout, Arg(vec![ArgPart::Literal(target_fd.to_string())]), src) - }), - // >> - map(preceded(tag(">>"), cut(preceded(multispace0, |i| parse_token(i, pctx)))), move |target| { - let src = if source_fd == -1 { 1 } else { source_fd }; - (RedirectMode::Append, target, src) - }), - // > - map(preceded(tag(">"), cut(preceded(multispace0, |i| parse_token(i, pctx)))), move |target| { - let src = if source_fd == -1 { 1 } else { source_fd }; - (RedirectMode::Overwrite, target, src) - }), - // < - map(preceded(tag("<"), cut(preceded(multispace0, |i| parse_token(i, pctx)))), move |target| { - let src = if source_fd == -1 { 0 } else { source_fd }; - (RedirectMode::Input, target, src) - }), - ))(input) -} - -// 4. Atomic: If, While, Subshell, Simple/Assignment -fn parse_atomic<'a>(input: &'a str, pctx: &ParserContext) -> IResult<&'a str, CommandExpr> { - alt(( - |i| parse_if(i, pctx), - |i| parse_while(i, pctx), - |i| parse_subshell(i, pctx), - |i| parse_simple(i, pctx) - ))(input) -} - -fn optional_separator<'a>(input: &'a str) -> IResult<&'a str, ()> { - let (input, _) = multispace0(input)?; - if let Ok((rem, _)) = char::<_, nom::error::Error<&str>>(';')(input) { - let (rem, _) = multispace0(rem)?; - Ok((rem, ())) - } else { - Ok((input, ())) - } -} - -// If -fn parse_if<'a>(input: &'a str, pctx: &ParserContext) -> IResult<&'a str, CommandExpr> { - let (input, _) = tag("if")(input)?; - let (input, _) = multispace1(input)?; - let (input, cond) = parse_sequence(input, pctx)?; - let (input, _) = optional_separator(input)?; - - let (input, _) = tag("then")(input)?; - let (input, _) = multispace1(input)?; - let (input, then_branch) = parse_sequence(input, pctx)?; - let (input, _) = optional_separator(input)?; - - let (input, else_branch) = match tag::<_, _, nom::error::Error<&str>>("else")(input) { - Ok((rem, _)) => { - let (rem, _) = multispace1(rem)?; - let (rem, branch) = parse_sequence(rem, pctx)?; - let (rem, _) = optional_separator(rem)?; - (rem, Some(Box::new(branch))) - }, - Err(_) => (input, None) - }; - - let (input, _) = tag("fi")(input)?; - - Ok((input, CommandExpr::If { - cond: Box::new(cond), - then_branch: Box::new(then_branch), - else_branch, - })) -} - -// While -fn parse_while<'a>(input: &'a str, pctx: &ParserContext) -> IResult<&'a str, CommandExpr> { - let (input, _) = tag("while")(input)?; - let (input, _) = multispace1(input)?; - let (input, cond) = parse_sequence(input, pctx)?; - let (input, _) = optional_separator(input)?; - - let (input, _) = tag("do")(input)?; - let (input, _) = multispace1(input)?; - let (input, body) = parse_sequence(input, pctx)?; - let (input, _) = optional_separator(input)?; - - let (input, _) = tag("done")(input)?; - - Ok((input, CommandExpr::While { - cond: Box::new(cond), - body: Box::new(body), - })) -} - -// Subshell -fn parse_subshell<'a>(input: &'a str, pctx: &ParserContext) -> IResult<&'a str, CommandExpr> { - let (input, _) = char('(')(input)?; - let (input, _) = multispace0(input)?; - let (input, cmd) = parse_sequence(input, pctx)?; - let (input, _) = multispace0(input)?; - let (input, _) = char(')')(input)?; - - Ok((input, CommandExpr::Subshell(Box::new(cmd)))) -} - -// Simple or Assignment -fn parse_simple<'a>(input: &'a str, pctx: &ParserContext) -> IResult<&'a str, CommandExpr> { - let (input, program) = parse_token(input, pctx)?; - - if let Some(s) = arg_as_static_keyword(&program) { - if is_keyword(&s) { - return Err(nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Tag))); - } - } - - let (input, args) = many0(preceded(multispace1, |i| parse_token(i, pctx)))(input)?; - - if args.is_empty() { - if let Some((key, value)) = check_assignment(&program) { - return Ok((input, CommandExpr::Assignment { key, value })); - } - } - - Ok((input, CommandExpr::Simple { program, args })) -} - -fn arg_as_static_keyword(arg: &Arg) -> Option { - if arg.0.len() == 1 { - if let ArgPart::Literal(ref s) = arg.0[0] { - return Some(s.clone()); - } - } - None -} - -fn check_assignment(arg: &Arg) -> Option<(String, Arg)> { - if let Some(first) = arg.0.first() { - if let ArgPart::Literal(s) = first { // Removed 'ref' - if let Some(idx) = s.find('=') { - if idx > 0 { - let key = s[..idx].to_string(); - if key.chars().all(|c| c.is_alphanumeric() || c == '_') { - let val_prefix = s[idx+1..].to_string(); - let mut value_parts = Vec::new(); - if !val_prefix.is_empty() { - value_parts.push(ArgPart::Literal(val_prefix)); - } - value_parts.extend_from_slice(&arg.0[1..]); - return Some((key, Arg(value_parts))); - } - } - } - } - } - None -} - -fn is_keyword(s: &str) -> bool { - matches!(s, "if" | "then" | "else" | "fi" | "while" | "do" | "done") -} - -fn parse_token<'a>(input: &'a str, pctx: &ParserContext) -> IResult<&'a str, Arg> { - let (input, parts_list) = many1(alt(( - parse_single_quoted, - |i| parse_double_quoted(i, pctx), - parse_escaped_char_part, - |i| parse_variable(i, pctx), - parse_unquoted_text_part - )))(input)?; - - let mut combined = Vec::new(); - for p in parts_list { - combined.extend(p); - } - Ok((input, Arg(combined))) -} - -fn parse_single_quoted(input: &str) -> IResult<&str, Vec> { - let (input, s) = delimited( - char('\''), - map(take_while(|c| c != '\''), |s: &str| s.to_string()), - char('\'') - )(input)?; - Ok((input, vec![ArgPart::Literal(s)])) -} - -fn parse_double_quoted<'a>(input: &'a str, pctx: &ParserContext) -> IResult<&'a str, Vec> { - let (input, _) = char('"')(input)?; - let (input, parts_list) = many0(alt(( - parse_escaped_char_part, - |i| parse_variable(i, pctx), - map(is_not("\"$\\"), |s: &str| vec![ArgPart::Literal(s.to_string())]) - )))(input)?; - let (input, _) = char('"')(input)?; - - let mut combined = Vec::new(); - for p in parts_list { - combined.extend(p); - } - Ok((input, combined)) -} - -fn parse_escaped_char_part(input: &str) -> IResult<&str, Vec> { - let (input, _) = char('\\')(input)?; - let (input, c) = satisfy(|_| true)(input)?; - Ok((input, vec![ArgPart::Literal(c.to_string())])) -} - -fn parse_variable<'a>(input: &'a str, _pctx: &ParserContext) -> IResult<&'a str, Vec> { - let (input, _) = char('$')(input)?; - - if let Ok((rem, _)) = char::<_, nom::error::Error<&str>>('{')(input) { - let (rem, name) = take_while1(|c: char| c != '}')(rem)?; - let (rem, _) = char('}')(rem)?; - return Ok((rem, vec![ArgPart::Variable(name.to_string())])); - } - - if let Ok((rem, _)) = char::<_, nom::error::Error<&str>>('?')(input) { - return Ok((rem, vec![ArgPart::Variable("?".to_string())])); - } - - let (input, name) = take_while1(|c: char| c.is_alphanumeric() || c == '_')(input)?; - Ok((input, vec![ArgPart::Variable(name.to_string())])) -} - -fn parse_unquoted_text_part(input: &str) -> IResult<&str, Vec> { - // Stop if we see start of redirect (digit followed by > or <) - if let Ok((_, _)) = peek(pair(digit1::<&str, Error<&str>>, one_of::<&str, &str, Error<&str>>("><")))(input) { - return Err(nom::Err::Error(Error::new(input, nom::error::ErrorKind::Tag))); - } - - take_while1(|c: char| !c.is_whitespace() && !is_quote(c) && c != '$' && c != '\\' && !is_operator_char(c))(input) - .map(|(next, res)| (next, vec![ArgPart::Literal(res.to_string())])) -} - -fn is_operator_char(c: char) -> bool { - "|&><;()".contains(c) -} - -fn is_quote(c: char) -> bool { - c == '\'' || c == '"' -} diff --git a/src/pas/tests.rs b/src/pas/tests.rs deleted file mode 100644 index 5f43f67..0000000 --- a/src/pas/tests.rs +++ /dev/null @@ -1,287 +0,0 @@ -use crate::pas::context::ShellContext; -use crate::pas::commands::builtins::env::cd::CdCommand; -use crate::pas::commands::builtins::fs::rm::RmCommand; -use crate::pas::commands::Executable; -use crate::pas::parser::parse_command_line; -use crate::pas::ast::{CommandExpr, Arg, ArgPart}; -use std::fs; - -fn lit(s: &str) -> Arg { - Arg(vec![ArgPart::Literal(s.to_string())]) -} - -fn var(s: &str) -> Arg { - Arg(vec![ArgPart::Variable(s.to_string())]) -} - -#[test] -fn test_parser_basic() { - let ctx = ShellContext::new(None); - let expr = parse_command_line("echo hello world", &ctx).unwrap(); - if let CommandExpr::Simple { program, args } = expr { - assert_eq!(program, lit("echo")); - assert_eq!(args, vec![lit("hello"), lit("world")]); - } else { - panic!("Expected Simple command"); - } -} - -#[test] -fn test_parser_env() { - let mut ctx = ShellContext::new(None); - ctx.env.insert("VAR".to_string(), "value".to_string()); - - let expr = parse_command_line("echo $VAR", &ctx).unwrap(); - if let CommandExpr::Simple { args, .. } = expr { - // Now it returns Variable part, not expanded value! - assert_eq!(args[0], var("VAR")); - } else { - panic!("Expected Simple command"); - } -} - -#[test] -fn test_cd_builtin() { - let mut ctx = ShellContext::new(None); - let cd = CdCommand; - // execute now expects expanded args (Vec) because executor calls it after expansion - cd.execute(&["cd".to_string(), "..".to_string()], &mut ctx, None, None, None).unwrap(); -} - -#[test] -fn test_rm_builtin() { - let mut ctx = ShellContext::new(None); - let test_file = ctx.cwd.join("test_file_rm.txt"); - fs::write(&test_file, "content").unwrap(); - - let rm = RmCommand; - rm.execute(&["rm".to_string(), "test_file_rm.txt".to_string()], &mut ctx, None, None, None).unwrap(); - - assert!(!test_file.exists()); -} - -#[test] -fn test_system_command_fallback() { - let mut ctx = ShellContext::new(None); - let res = crate::pas::run_command_line("echo system_test", &mut ctx); - assert!(res.is_ok()); - assert_eq!(res.unwrap(), 0); -} - -#[test] -fn test_redirect_output() { - let mut ctx = ShellContext::new(None); - let out_file = ctx.cwd.join("test_redirect.txt"); - if out_file.exists() { fs::remove_file(&out_file).unwrap(); } - - let cmd = format!("echo hello > {}", out_file.to_string_lossy()); - crate::pas::run_command_line(&cmd, &mut ctx).unwrap(); - - assert!(out_file.exists()); - let content = fs::read_to_string(&out_file).unwrap(); - assert!(content.trim() == "hello"); - fs::remove_file(out_file).unwrap(); -} - -#[test] -fn test_logic_and() { - let mut ctx = ShellContext::new(None); - let out_file = ctx.cwd.join("test_and.txt"); - if out_file.exists() { fs::remove_file(&out_file).unwrap(); } - - let cmd = format!("echo 1 && echo 2 > {}", out_file.to_string_lossy()); - crate::pas::run_command_line(&cmd, &mut ctx).unwrap(); - - assert!(out_file.exists()); - fs::remove_file(out_file).unwrap(); -} - -#[test] -fn test_pipe_simple() { - let mut ctx = ShellContext::new(None); - let out_file = ctx.cwd.join("test_pipe.txt"); - - if cfg!(unix) { - let cmd = format!("echo hello | grep hello > {}", out_file.to_string_lossy()); - crate::pas::run_command_line(&cmd, &mut ctx).unwrap(); - let content = fs::read_to_string(&out_file).unwrap(); - assert!(content.contains("hello")); - } - - if out_file.exists() { fs::remove_file(out_file).unwrap(); } -} - -#[test] -fn test_variable_assignment() { - let mut ctx = ShellContext::new(None); - crate::pas::run_command_line("A=10", &mut ctx).unwrap(); - assert_eq!(ctx.env.get("A").unwrap(), "10"); -} - -#[test] -fn test_variable_expansion_delayed() { - let mut ctx = ShellContext::new(None); - // This previously failed with static expansion - crate::pas::run_command_line("A=10; echo $A", &mut ctx).unwrap(); - // We can't easily check stdout here but we verified assignment works. - // We can use a side effect. - crate::pas::run_command_line("A=file_delayed.txt; echo content > $A", &mut ctx).unwrap(); - assert!(ctx.cwd.join("file_delayed.txt").exists()); - fs::remove_file("file_delayed.txt").unwrap(); -} - -#[test] -fn test_if_else() { - let mut ctx = ShellContext::new(None); - crate::pas::run_command_line("if true; then A=yes; else A=no; fi", &mut ctx).unwrap(); - assert_eq!(ctx.env.get("A").unwrap(), "yes"); - - crate::pas::run_command_line("if false; then B=yes; else B=no; fi", &mut ctx).unwrap(); - assert_eq!(ctx.env.get("B").unwrap(), "no"); -} - -#[test] -fn test_while_loop() { - let mut ctx = ShellContext::new(None); - if cfg!(unix) { - // Now this should work because $A is expanded at runtime - crate::pas::run_command_line("A=0; while test $A -ne 1; do A=1; done", &mut ctx).unwrap(); - assert_eq!(ctx.env.get("A").unwrap(), "1"); - } -} - -#[test] -fn test_subshell() { - let mut ctx = ShellContext::new(None); - ctx.env.insert("OUTER".to_string(), "original".to_string()); - - crate::pas::run_command_line("(OUTER=changed; INNER=created)", &mut ctx).unwrap(); - - // Parent env should NOT change - assert_eq!(ctx.env.get("OUTER").unwrap(), "original"); - assert!(ctx.env.get("INNER").is_none()); -} - -#[test] -fn test_sequence() { - let mut ctx = ShellContext::new(None); - crate::pas::run_command_line("A=1; A=2", &mut ctx).unwrap(); - assert_eq!(ctx.env.get("A").unwrap(), "2"); -} - -#[test] -fn test_tilde_expansion() { - let mut ctx = ShellContext::new(None); - // Mock HOME - let home = std::env::temp_dir().join("mock_home"); - fs::create_dir_all(&home).unwrap(); - ctx.env.insert("HOME".to_string(), home.to_string_lossy().to_string()); - - // Test simple tilde - crate::pas::run_command_line("echo ~ > ~/tilde_test.txt", &mut ctx).unwrap(); - - let expected_path = home.join("tilde_test.txt"); - assert!(expected_path.exists()); - let content = fs::read_to_string(&expected_path).unwrap(); - // echo ~ prints the home path. Note: echo adds newline, trim removes it. - assert_eq!(content.trim(), home.to_string_lossy().trim()); - - // Cleanup - fs::remove_dir_all(home).unwrap(); -} - -#[test] -fn test_stderr_redirect() { - let mut ctx = ShellContext::new(None); - let out_file = ctx.cwd.join("test_err.txt"); - if out_file.exists() { fs::remove_file(&out_file).unwrap(); } - - // mv without args prints to stderr - let cmd = format!("mv 2> {}", out_file.to_string_lossy()); - crate::pas::run_command_line(&cmd, &mut ctx).unwrap(); - - assert!(out_file.exists()); - let content = fs::read_to_string(&out_file).unwrap(); - assert!(content.contains("Usage:")); - fs::remove_file(out_file).unwrap(); -} - -#[test] -fn test_merge_stderr() { - let mut ctx = ShellContext::new(None); - let out_file = ctx.cwd.join("test_merge.txt"); - if out_file.exists() { fs::remove_file(&out_file).unwrap(); } - - // mv > file 2>&1 - let cmd = format!("mv > {} 2>&1", out_file.to_string_lossy()); - crate::pas::run_command_line(&cmd, &mut ctx).unwrap(); - - assert!(out_file.exists()); - let content = fs::read_to_string(&out_file).unwrap(); - assert!(content.contains("Usage:")); - fs::remove_file(out_file).unwrap(); -} - -#[test] -fn test_security_exec() { - use crate::config::CapabilityConfig; - let caps = CapabilityConfig { - allow_exec: Some(vec!["echo".to_string()]), - allow_paths: None, - }; - let mut ctx = ShellContext::new(Some(caps)); - - // echo is likely system command fallback if not builtin, OR builtin. - // echo is usually builtin in simple shells but PaShell might not have it. - // If echo is NOT builtin, it goes to SystemCommand. - // If echo IS builtin, it bypasses allow_exec logic in SystemCommand. - // Let's assume echo is SystemCommand for test (or check). - // If echo is allowed, it passes. - - // Try a known system command 'whoami' or 'true'. - let res = crate::pas::run_command_line("true", &mut ctx); - // 'true' not in allowed -> 126. - assert_eq!(res.unwrap(), 126); - - // Allow 'true' - let caps2 = CapabilityConfig { - allow_exec: Some(vec!["true".to_string()]), - allow_paths: None, - }; - let mut ctx2 = ShellContext::new(Some(caps2)); - let res = crate::pas::run_command_line("true", &mut ctx2); - assert_eq!(res.unwrap(), 0); -} - -#[test] -fn test_security_fs() { - use crate::config::CapabilityConfig; - use std::fs; - let allowed_dir = std::env::temp_dir().join("allowed_zone"); - if !allowed_dir.exists() { fs::create_dir_all(&allowed_dir).unwrap(); } - let allowed_str = allowed_dir.to_string_lossy().to_string(); - - let caps = CapabilityConfig { - allow_exec: None, - allow_paths: Some(vec![allowed_str]), - }; - let mut ctx = ShellContext::new(Some(caps)); - - // mkdir inside allowed - let sub = allowed_dir.join("sub"); - if sub.exists() { fs::remove_dir(&sub).unwrap(); } - let cmd = format!("mkdir {}", sub.to_string_lossy()); - let res = crate::pas::run_command_line(&cmd, &mut ctx); - assert_eq!(res.unwrap(), 0); - assert!(sub.exists()); - - // mkdir outside allowed - let forbidden = std::env::temp_dir().join("forbidden_zone"); - let cmd = format!("mkdir {}", forbidden.to_string_lossy()); - let res = crate::pas::run_command_line(&cmd, &mut ctx); - - // Should fail (Err because bail!) - assert!(res.is_err()); - - fs::remove_dir_all(allowed_dir).unwrap(); -} diff --git a/src/runner/cache.rs b/src/runner/cache.rs index e59e774..f7ee329 100644 --- a/src/runner/cache.rs +++ b/src/runner/cache.rs @@ -2,6 +2,7 @@ use anyhow::{Result, Context}; use std::fs; use std::path::{Path, PathBuf}; use std::io::Read; +use std::collections::HashMap; const CACHE_DIR: &str = ".p/cache"; @@ -30,7 +31,7 @@ fn get_cache_path(task_name: &str) -> PathBuf { Path::new(CACHE_DIR).join(format!("{}.hash", safe_name)) } -pub fn compute_hash(sources: &[String]) -> Result { +pub fn compute_hash(sources: &[String], env: &HashMap) -> Result { let mut hasher = blake3::Hasher::new(); let mut file_paths = Vec::new(); @@ -63,10 +64,23 @@ pub fn compute_hash(sources: &[String]) -> Result { } } + // Hash environment variables + let mut env_keys: Vec<_> = env.keys().collect(); + env_keys.sort(); + + for key in env_keys { + if let Some(val) = env.get(key) { + hasher.update(key.as_bytes()); + hasher.update(b"="); + hasher.update(val.as_bytes()); + hasher.update(b"\n"); + } + } + Ok(hasher.finalize().to_hex().to_string()) } -pub fn is_up_to_date(task_name: &str, sources: &[String], outputs: &[String]) -> Result { +pub fn is_up_to_date(task_name: &str, sources: &[String], outputs: &[String], env: &HashMap) -> Result { ensure_cache_setup()?; // 1. Check if all outputs exist @@ -94,7 +108,7 @@ pub fn is_up_to_date(task_name: &str, sources: &[String], outputs: &[String]) -> } // 2. Check Hash - let current_hash = compute_hash(sources)?; + let current_hash = compute_hash(sources, env)?; let cache_path = get_cache_path(task_name); if !cache_path.exists() { @@ -106,9 +120,9 @@ pub fn is_up_to_date(task_name: &str, sources: &[String], outputs: &[String]) -> Ok(current_hash.trim() == cached_hash.trim()) } -pub fn save_cache(task_name: &str, sources: &[String]) -> Result<()> { +pub fn save_cache(task_name: &str, sources: &[String], env: &HashMap) -> Result<()> { ensure_cache_setup()?; - let current_hash = compute_hash(sources)?; + let current_hash = compute_hash(sources, env)?; let cache_path = get_cache_path(task_name); fs::write(cache_path, current_hash)?; Ok(()) diff --git a/src/runner/common.rs b/src/runner/common.rs index f274754..c5eba4a 100644 --- a/src/runner/common.rs +++ b/src/runner/common.rs @@ -1,6 +1,49 @@ use anyhow::Result; use std::fs; use std::path::Path; +use glob::glob; + +pub fn expand_globs(args: &[String]) -> Vec { + let mut expanded_args = Vec::new(); + + for arg in args { + // Skip flags + if arg.starts_with('-') { + expanded_args.push(arg.clone()); + continue; + } + + // Check for glob characters + if arg.contains('*') || arg.contains('?') || arg.contains('[') { + match glob(arg) { + Ok(paths) => { + let mut matched_paths = Vec::new(); + for entry in paths { + if let Ok(path) = entry { + matched_paths.push(path.to_string_lossy().to_string()); + } + } + + if matched_paths.is_empty() { + // No matches found, keep original argument (bash behavior) + expanded_args.push(arg.clone()); + } else { + // Sort to ensure deterministic behavior (like shell expansion) + matched_paths.sort(); + expanded_args.extend(matched_paths); + } + }, + Err(_) => { + // Invalid pattern, keep original argument + expanded_args.push(arg.clone()); + } + } + } else { + expanded_args.push(arg.clone()); + } + } + expanded_args +} pub fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { if !dst.exists() { @@ -21,3 +64,35 @@ pub fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + + #[test] + fn test_expand_globs() { + // Setup + let _ = File::create("test_glob_a.tmp"); + let _ = File::create("test_glob_b.tmp"); + + let args = vec!["test_glob_*.tmp".to_string()]; + let expanded = expand_globs(&args); + + // Teardown + let _ = fs::remove_file("test_glob_a.tmp"); + let _ = fs::remove_file("test_glob_b.tmp"); + + assert_eq!(expanded.len(), 2); + assert!(expanded.contains(&"test_glob_a.tmp".to_string())); + assert!(expanded.contains(&"test_glob_b.tmp".to_string())); + } + + #[test] + fn test_expand_globs_no_match() { + let args = vec!["*.nomatch".to_string()]; + let expanded = expand_globs(&args); + assert_eq!(expanded.len(), 1); + assert_eq!(expanded[0], "*.nomatch"); + } +} diff --git a/src/runner/handler/cat.rs b/src/runner/handler/cat.rs index 811f0c4..881ae65 100644 --- a/src/runner/handler/cat.rs +++ b/src/runner/handler/cat.rs @@ -2,25 +2,35 @@ use anyhow::{Result, Context}; use std::fs; +use std::io; use std::path::Path; +use crate::runner::common::expand_globs; pub fn handle_cat(args: &[String]) -> Result<()> { - if args.len() < 2 { + let expanded_args = expand_globs(args); + + if expanded_args.is_empty() { println!("Usage: cat ..."); return Ok(()); } - for filename in &args[1..] { + for filename in &expanded_args { let path = Path::new(filename); if !path.exists() { println!("cat: {}: No such file", filename); continue; } + + if path.is_dir() { + println!("cat: {}: Is a directory", filename); + continue; + } - let content = fs::read_to_string(path) + let mut file = fs::File::open(path) + .with_context(|| format!("Failed to open file: {}", filename))?; + io::copy(&mut file, &mut io::stdout()) .with_context(|| format!("Failed to read file: {}", filename))?; - print!("{}", content); } Ok(()) -} \ No newline at end of file +} diff --git a/src/runner/handler/cp.rs b/src/runner/handler/cp.rs index 7d63441..17930b6 100644 --- a/src/runner/handler/cp.rs +++ b/src/runner/handler/cp.rs @@ -4,12 +4,15 @@ use anyhow::{Result, Context, bail}; use std::fs; use std::path::Path; use crate::runner::common::copy_dir_recursive; +use crate::runner::common::expand_globs; pub fn handle_cp(args: &[String]) -> Result<()> { + let expanded_args = expand_globs(args); + let mut recursive = false; let mut paths = Vec::new(); - for arg in args { + for arg in &expanded_args { if arg == "-r" || arg == "-R" || arg == "--recursive" { recursive = true; } else { @@ -32,7 +35,7 @@ pub fn handle_cp(args: &[String]) -> Result<()> { } for src in sources { - let src_path = Path::new(&src); + let src_path = Path::new(src); // No need for &src here as src is String (actually &String if from &expanded_args, wait) if !src_path.exists() { bail!("Source not found: {}", src); } @@ -55,4 +58,4 @@ pub fn handle_cp(args: &[String]) -> Result<()> { } Ok(()) -} \ No newline at end of file +} diff --git a/src/runner/handler/ls.rs b/src/runner/handler/ls.rs index 21235c4..925da56 100644 --- a/src/runner/handler/ls.rs +++ b/src/runner/handler/ls.rs @@ -3,22 +3,45 @@ use anyhow::{Result, Context}; use std::fs; use std::path::Path; +use crate::runner::common::expand_globs; pub fn handle_ls(args: &[String]) -> Result<()> { - let path = if args.is_empty() { - "." - } else { - &args[0] - }; + let mut expanded_args = expand_globs(args); - let entries = fs::read_dir(Path::new(path)) - .with_context(|| format!("Failed to read directory: {}", path))?; - - for entry in entries { - let entry = entry?; - let file_name = entry.file_name(); - println!("{}", file_name.to_string_lossy()); + if expanded_args.is_empty() { + expanded_args.push(".".to_string()); } + let show_header = expanded_args.len() > 1; + + for path_str in expanded_args { + let path = Path::new(&path_str); + if !path.exists() { + println!("ls: {}: No such file or directory", path_str); + continue; + } + + if path.is_dir() { + if show_header { + println!("{}:", path_str); + } + + let mut entries_vec = Vec::new(); + let read_dir = fs::read_dir(path).with_context(|| format!("Failed to read directory: {}", path_str))?; + + for entry in read_dir { + entries_vec.push(entry?.file_name()); + } + + // Sort for consistent output + entries_vec.sort(); + + for name in entries_vec { + println!("{}", name.to_string_lossy()); + } + } else { + println!("{}", path_str); + } + } Ok(()) -} \ No newline at end of file +} diff --git a/src/runner/handler/mv.rs b/src/runner/handler/mv.rs index 913539c..0102d95 100644 --- a/src/runner/handler/mv.rs +++ b/src/runner/handler/mv.rs @@ -1,18 +1,49 @@ // Mv portable handler -use anyhow::{Result, Context}; +use anyhow::{Result, Context, bail}; use std::fs; use std::path::Path; +use crate::runner::common::expand_globs; pub fn handle_mv(args: &[String]) -> Result<()> { - if args.len() != 2 { - anyhow::bail!("mv command requires exactly 2 arguments: source and destination"); + let expanded_args = expand_globs(args); + + let mut paths = Vec::new(); + // We ignore flags for now, but filter them out to avoid treating them as paths + for arg in &expanded_args { + if !arg.starts_with('-') { + paths.push(arg); + } + } + + if paths.len() < 2 { + bail!("mv command requires at least source and destination"); } - let src = Path::new(&args[0]); - let dst = Path::new(&args[1]); + let dest = paths.pop().unwrap(); + let sources = paths; - fs::rename(src, dst).with_context(|| format!("Failed to move from {:?} to {:?}", src, dst))?; + let dest_path = Path::new(dest); + let dest_is_dir = dest_path.is_dir(); + + if sources.len() > 1 && !dest_is_dir { + bail!("Target '{}' is not a directory", dest); + } + + for src in sources { + let src_path = Path::new(src); + if !src_path.exists() { + bail!("Source not found: {}", src); + } + + let target = if dest_is_dir { + dest_path.join(src_path.file_name().ok_or_else(|| anyhow::anyhow!("Invalid source filename"))?) + } else { + dest_path.to_path_buf() + }; + + fs::rename(src_path, &target).with_context(|| format!("Failed to move from {:?} to {:?}", src_path, target))?; + } Ok(()) -} \ No newline at end of file +} diff --git a/src/runner/handler/rm.rs b/src/runner/handler/rm.rs index 85f0e2b..b63725b 100644 --- a/src/runner/handler/rm.rs +++ b/src/runner/handler/rm.rs @@ -3,13 +3,16 @@ use anyhow::{Result, Context, bail}; use std::fs; use std::path::Path; +use crate::runner::common::expand_globs; pub fn handle_rm(args: &[String]) -> Result<()> { + let args = expand_globs(args); + let mut recursive = false; let mut force = false; let mut paths = Vec::new(); - for arg in args { + for arg in &args { if arg.starts_with('-') { if arg.contains('r') || arg.contains('R') { recursive = true; } if arg.contains('f') { force = true; } @@ -38,4 +41,4 @@ pub fn handle_rm(args: &[String]) -> Result<()> { } } Ok(()) -} \ No newline at end of file +} diff --git a/src/runner/mod.rs b/src/runner/mod.rs index dbe2dca..f4f6a3f 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -11,49 +11,12 @@ use std::time::Duration; use rayon::prelude::*; use crate::config::PavidiConfig; use crate::utils::{detect_shell, expand_command, run_shell_command, CaptureMode}; -use crate::pas::context::ShellContext; -use crate::pas::run_command_line; use crate::logger::write_log; use self::task::RunnerTask; 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::io::Write; -use std::sync::{Arc, Mutex}; - -#[derive(Clone)] -struct PasTeeWriter { - buffer: Arc>>, - inner: Arc>>>, -} - -impl PasTeeWriter { - fn new(buffer: Arc>>, inner: Option>) -> Self { - Self { - buffer, - inner: Arc::new(Mutex::new(inner)), - } - } -} - -impl Write for PasTeeWriter { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.buffer.lock().unwrap().extend_from_slice(buf); - if let Some(inner) = self.inner.lock().unwrap().as_mut() { - inner.write(buf) - } else { - Ok(buf.len()) - } - } - fn flush(&mut self) -> std::io::Result<()> { - if let Some(inner) = self.inner.lock().unwrap().as_mut() { - inner.flush() - } else { - Ok(()) - } - } -} pub struct CallStack { stack: HashSet, @@ -91,8 +54,7 @@ pub fn recursive_runner( call_stack: &mut CallStack, extra_args: &[String], capture_output: bool, // true = buffer output (for parallel), false = inherit - dry_run: bool, - mut context: Option<&mut ShellContext> + dry_run: bool ) -> Result<()> { call_stack.push(task_name)?; @@ -100,11 +62,11 @@ pub fn recursive_runner( let task = runner_section.get(task_name).expect("Task check passed before"); // Destructure task config - let (mut cmds, deps, parallel_deps, sources, outputs, windows, linux, macos, ignore_failure, timeout_sec) = match task { - RunnerTask::Single(cmd) => (vec![cmd.clone()], vec![], false, None, None, None, None, None, false, None), - RunnerTask::List(cmds) => (cmds.clone(), vec![], false, None, None, None, None, None, false, None), - RunnerTask::Full { cmds, deps, parallel, sources, outputs, windows, linux, macos, ignore_failure, timeout, .. } => - (cmds.clone(), deps.clone(), *parallel, 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) = 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), }; // 1. Run Dependencies @@ -116,20 +78,15 @@ pub fn recursive_runner( // Snapshot the stack to avoid capturing &mut CallStack in the closure let stack_snapshot = call_stack.clone_stack(); - // Snapshot context for parallel execution - let context_snapshot = context.as_ref().map(|c| (**c).clone()); // Rayon parallel iterator let errors: Vec = deps .par_iter() .map(|dep_name| { let mut local_stack = stack_snapshot.clone_stack(); - // Clone context for this thread - let mut local_ctx_val = context_snapshot.clone(); - let local_ctx = local_ctx_val.as_mut(); // Parallel deps MUST capture output to prevent mixed logs - recursive_runner(dep_name, config, &mut local_stack, &[], true, dry_run, local_ctx) + recursive_runner(dep_name, config, &mut local_stack, &[], true, dry_run) .map_err(|e| format!("Dep '{}' failed: {}", dep_name, e)) }) .filter_map(|res| res.err()) @@ -144,14 +101,48 @@ pub fn recursive_runner( info!("{} Running dependencies sequentially...", "🔗".blue()); } for dep in deps { - recursive_runner(&dep, config, call_stack, &[], capture_output, dry_run, context.as_deref_mut())?; + recursive_runner(&dep, config, call_stack, &[], capture_output, dry_run)?; } } } - // 2. Check Conditional Execution (Cache Check) + // 2. Logic Gates (Conditional Execution) + // Detect shell (needed for condition checks) + let shell_pref = config.project.as_ref().and_then(|p| p.shell.as_ref()) + .or(config.module.as_ref().and_then(|m| m.shell.as_ref())); + let shell_cmd = detect_shell(shell_pref); + + // skip_if + if let Some(raw_cmd) = skip_if { + 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 code == 0 { + if !capture_output { + info!("{} Skipping task '{}' because 'skip_if' condition met.", "⏭️".yellow(), task_name.bold()); + } + call_stack.pop(task_name); + return Ok(()); + } + } + + // run_if + if let Some(raw_cmd) = run_if { + 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 code != 0 { + if !capture_output { + info!("{} Skipping task '{}' because 'run_if' condition failed.", "⏭️".yellow(), task_name.bold()); + } + call_stack.pop(task_name); + return Ok(()); + } + } + + // 3. Check Conditional Execution (Cache Check) if let (Some(srcs), Some(outs)) = (&sources, &outputs) { - if is_up_to_date(task_name, srcs, outs)? { + if is_up_to_date(task_name, srcs, outs, &config.env)? { if !capture_output { info!("{} Task '{}' is up-to-date. Skipping.", "✨".green(), task_name.bold()); } @@ -160,7 +151,7 @@ pub fn recursive_runner( } } - // 3. Execute Main Commands + // 4. Execute Main Commands // OS Detection & Command Selection let os = std::env::consts::OS; @@ -204,12 +195,9 @@ pub fn recursive_runner( CaptureMode::Inherit } }; - - // Optimize Core Logic - detect shell - let shell_pref = config.project.as_ref().and_then(|p| p.shell.as_ref()) - .or(config.module.as_ref().and_then(|m| m.shell.as_ref())); - let shell_cmd = detect_shell(shell_pref); + // 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)), @@ -233,100 +221,47 @@ pub fn recursive_runner( let mut captured_output = String::new(); let mut exit_code = 0; - // Execute using PAS if context is available - if let Some(ctx) = &mut context { - let res = if capture_mode != CaptureMode::Inherit { - let buf = Arc::new(Mutex::new(Vec::new())); - // Create writers. If Tee, print to Stdout/Stderr. If Buffer, None. - let (out_writer, err_writer) = if capture_mode == CaptureMode::Tee { - (Some(Box::new(PasTeeWriter::new(buf.clone(), Some(Box::new(std::io::stdout())))) as Box), - Some(Box::new(PasTeeWriter::new(buf.clone(), Some(Box::new(std::io::stderr())))) as Box)) - } else { - // Buffer mode: Capture only. - (Some(Box::new(PasTeeWriter::new(buf.clone(), None)) as Box), - Some(Box::new(PasTeeWriter::new(buf.clone(), None)) as Box)) - }; - - let r = run_command_line(&final_cmd, ctx, out_writer, err_writer); - - let output_bytes = buf.lock().unwrap(); - captured_output = String::from_utf8_lossy(&output_bytes).to_string(); - - if capture_mode == CaptureMode::Buffer && !captured_output.trim().is_empty() { - info!("[{}] output:\n{}", task_name.cyan(), captured_output.trim()); + // 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; } - r - } else { - run_command_line(&final_cmd, ctx, None, None) - }; - - match res { - Ok(0) => exit_code = 0, - Ok(code) => { + 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 log_enabled { - let _ = write_log(task_name, &final_cmd, &captured_output, config, start_time.elapsed(), exit_code, &config.env); - } - if ignore_failure { - log::warn!("{} Command failed with exit code {} but ignored", "⚠️".yellow(), code); - } else { - bail!("❌ Task '{}' failed at: '{}' -> Exit code {}", task_name, final_cmd, 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) => { - // PAS execution error (not command exit code) + // Execution error (timeout, etc) if log_enabled { - let _ = write_log(task_name, &final_cmd, &format!("Internal Error: {}\nPartial Output:\n{}", e, captured_output), config, start_time.elapsed(), 1, &config.env); - } - if ignore_failure { - log::warn!("{} Command failed but ignored: {}", "⚠️".yellow(), e); - continue; + let _ = write_log(task_name, &final_cmd, &format!("Execution Error: {}", e), config, start_time.elapsed(), 1, &config.env); } - bail!("❌ Task '{}' failed at: '{}' -> {}", task_name, final_cmd, e); - } - } - } else { - // 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); - } } } } + if log_enabled { if let Ok(Some(path)) = write_log(task_name, &final_cmd, &captured_output, config, start_time.elapsed(), exit_code, &config.env) { @@ -337,7 +272,7 @@ pub fn recursive_runner( // 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)?; + save_cache(task_name, srcs, &config.env)?; } } diff --git a/src/runner/task.rs b/src/runner/task.rs index 99d87ac..e27fa85 100644 --- a/src/runner/task.rs +++ b/src/runner/task.rs @@ -18,7 +18,10 @@ pub enum RunnerTask { // Description for listing #[serde(default)] description: Option, + // Conditional Execution + run_if: Option, + skip_if: Option, sources: Option>, outputs: Option>, diff --git a/wix/main.wxs b/wix/main.wxs new file mode 100644 index 0000000..104ddee --- /dev/null +++ b/wix/main.wxs @@ -0,0 +1,228 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 1 + + + + + + + + + + + + + + + + + +