diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bfb0a5b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `src/` contains the Rust CLI/TUI implementation; entry points live in `src/main.rs` and shared logic in `src/lib.rs`. +- `src/tests.rs` and `src/tests/` hold unit tests (currently `src/tests/config_test.rs`). +- `main.ts` is the Deno entry point for the published package. +- `deno.json` and `.huk.json` define tasks and hook configuration; `schema.json` documents the config schema. +- Build outputs land in `bin/` (custom artifacts) and `target/` (Cargo defaults). + +## Build, Test, and Development Commands +- `deno task build` builds the release binary into `bin/` (uses Cargo under the hood). +- `deno task build:debug` builds a debug binary into `bin/`. +- `deno task test` runs `cargo test --all`; use `deno task test:verbose` for full logs. +- `deno task fmt` / `deno task fmt:check` format or verify formatting. +- `deno task lint` runs `cargo clippy --all --all-targets -- -D warnings`. +- `cargo run --bin huk -- ` runs the CLI locally (e.g., `cargo run --bin huk -- dashboard`). + +## Coding Style & Naming Conventions +- Rust edition is 2024; formatting is enforced by `.rustfmt.toml` (80 column max, 2-space tabs, item-level imports). +- Use `cargo fmt` before commits and keep Clippy clean (`deno task lint`). +- Follow Rust conventions: `snake_case` for modules/functions/tests (e.g., `parse_task_spec_string`), `PascalCase` for types, and `SCREAMING_SNAKE_CASE` for constants. + +## Testing Guidelines +- Use `cargo test --all` or `deno task test`; tests live in `src/tests.rs` and `src/tests/*.rs`. +- Add targeted tests for config parsing, hook resolution, and task execution paths. + +## Commit & Pull Request Guidelines +- Commit messages follow Conventional Commits (`feat(tui): add tasks view`, `docs: update README`), with optional `[WIP]` suffix when needed. +- PRs should include a concise summary, tests run, and note any config schema or hook changes. +- Include screenshots or short clips for TUI-facing changes. + +## Configuration & Hook Definitions +- Define hooks in `deno.json` or `.huk.json` under the `hooks` field; tasks live under `tasks`. +- When changing config formats or validation, update `schema.json` and add/adjust tests. diff --git a/Cargo.lock b/Cargo.lock index 9d24ee8..198e837 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -709,9 +709,9 @@ dependencies = [ [[package]] name = "moos" -version = "0.1.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352be24acc3195d5ca0673318d0ae8320f9e51b4d3093eb1deffc72d5300c3d9" +checksum = "a418c89f2b6961d06174252b76d8992d95b98c891ca66d1f7cff2f0f30851b6e" dependencies = [ "derive_more", "serde", diff --git a/Cargo.toml b/Cargo.toml index 3a776ae..c152a08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,7 +71,7 @@ derive_more = { version = "2.1", features = [ "index", "index_mut", ] } -moos = "0.1.0" +moos = "0.3.0" toml = { version = "0.9.8", features = ["preserve_order"] } paste = { version = "0.2.0", package = "pastey" } chrono = { version = "0.4.42", optional = true } diff --git a/README.md b/README.md index d825f28..092da17 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ just before committing (`pre‑commit`), when preparing a commit message (`prepare‑commit‑msg`) or before pushing (`pre‑push`). Git’s documentation describes a rich set of client‑side hooks, including `pre‑commit`, `prepare‑commit‑msg`, `commit‑msg`, `post‑commit`, `pre‑rebase` and -`pre‑push`【23307213681274†L240-L330】. Setting up and distributing these +`pre‑push`. Setting up and distributing these scripts across multiple environments can be cumbersome. hük centralizes hook definitions alongside your project’s existing task configuration, making it simple to install and manage them. @@ -24,7 +24,7 @@ If your project targets Node.js you can also specify a `packageManager` field in `npm@x.y.z`, `pnpm@x.y.z` and `yarn@x.y.z`. The [Corepack](https://nodejs.org/docs/latest/api/cli.html#corepack) tool uses this field to download and select the appropriate package manager; hük respects it -and falls back to `npm` when unspecified【349948098167533†L48-L59】. +and falls back to `npm` when unspecified. ## Installation @@ -63,7 +63,7 @@ Tasks can refer to: If both a `deno.json` and a `package.json` are present, hük prefers the `deno.json` and falls back to `package.json`. When executing Node scripts hük -honours the `packageManager` field if present【349948098167533†L48-L59】. +honours the `packageManager` field if present. ### Example (Deno) @@ -100,7 +100,7 @@ honours the `packageManager` field if present【349948098167533†L48-L59】. "lint", { "command": "npm run test", "description": "Run tests" } ], - "commit-msg": { "command": "echo Validate commit message" } + "commit-msg": "npx git-cz", } } ``` diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 015089e..94dd1fd 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,13 @@ [toolchain] channel = "nightly" - components = ["rustfmt", "clippy"] + components = ["rustfmt", "clippy", "cargo"] + targets = [ + "aarch64-unknown-linux-gnu", + "aarch64-unknown-linux-musl", + "aarch64-apple-darwin", + "aarch64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "x86_64-apple-darwin", + "x86_64-pc-windows-msvc", + ] diff --git a/src/cli.rs b/src/cli.rs index 23c8827..73759c2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,11 +4,14 @@ //! the `huk` executable exposes. It uses the [`clap`](https://crates.io/crates/clap) //! crate for ergonomic argument parsing. +use std::path::PathBuf; + use clap::Args; use clap::Parser; use clap::Subcommand; use derive_more::with_trait::IsVariant; use derive_more::with_trait::TryInto; +use lazy_static::lazy_static; use paste::paste; use thiserror::Error; @@ -46,6 +49,10 @@ pub struct Cli { pub command: Commands, } +lazy_static! { + static ref LAZY_CWD: PathBuf = std::env::current_dir().unwrap_or_default(); +} + macro_rules! cli { ( $( @@ -113,6 +120,21 @@ macro_rules! cli { }; } +const TASK_SPEC_LONG_HELP: &str = "\ + Task specification to associate with the hook.\n\n\ + Accepted task specification forms:\n \ + 1. a raw shell command string (e.g. `\"git add -A\"`)\n \ + 2. a task name from the configuration file, which must either be:\n \ + - defined in the `tasks` section of a deno.json file, or ...\n \ + - defined in the `scripts` section of a package.json file\n \ + 3. an object with `command`, `dependencies`, and/or `description` fields, where:\n \ + - `command` is a shell command string to execute,\n \ + - `dependencies` is an array of tasks to run before the command,\n \ + Note: this field is required if `command` is not provided.\n \ + - `description` is a human-readable summary of the task (optional)\n \ + 4. a sequence where value satisfies either type 1, 2, or 3 a `bove.\n \ + Multiple specifications can be provided to build a sequence."; + cli! { /// Launch an interactive dashboard for managing hooks and tasks. #[command( @@ -140,7 +162,12 @@ cli! { ←|→ (left / right)\n \ Reposition the cursor in text fields.\n")] #[cfg(feature = "tui")] - Dashboard(Default), + Dashboard(Default) { + /// Set the working directory to run the huk dashboard in. + /// + /// Defaults to the current working directory. + cwd(long, short = 'C', default_value = LAZY_CWD.to_str()): Option, + }, /// List configured Git hooks and associated tasks. #[command( aliases = ["ls", "l", "hooks"], @@ -157,21 +184,21 @@ cli! { ): bool, /// Only output hook names without associated tasks. name_only(long, short = 'n'): bool, - /// Format the results as standard JSON (JavaScript Object Notation). + /// Format results as JSON (JavaScript Object Notation). json(long, short = 'j'): bool, - /// Format the results as YAML (YAML Ain't Markup Language). - yaml(long, short = 'y', long_help = "Format the results as YAML (YAML \ + /// Format results as YAML (YAML Ain't Markup Language). + yaml(long, short = 'y', long_help = "Format results as YAML (YAML \ Ain't Markup Language).\n\nNote: this currently ignores the --compact flag."): bool, - /// Format the results as TOML (Tom's Obvious, Minimal Language). + /// Format results as TOML (Tom's Obvious, Minimal Language). toml(long, short = 't'): bool, - /// Outputs a static list of names of all Git hooks that `huk` supports. + /// Output a static list of names of all Git hooks that `huk` supports. all( long, short = 'a', - long_help = "Output a list of names of all the Git hooks supported by \ - `huk`.\n\nUnlike other list options, this is unrelated to configuration.\n\ - It returns an immutable list of Git hook names (like 'pre-commit'),\n\ - indicating all of the hooks supported and understood by `huk`." + long_help = "Outputs the names of all Git hooks supported by `huk`.\n\n\ + Unlike other list options, this is unrelated to configuration,\n\ + and returns an immutable list of hook names (like 'pre-commit',\n\ + 'post-checkout', etc.) supported as keys in the 'hooks' object." ): bool, }, /// Run the tasks for the specified hook name. @@ -186,11 +213,10 @@ cli! { hook(): String, /// Additional arguments to forward to the hook runner. args( - last = true, long_help = "Additional arguments to forward to the hook runner.\n\n\ - Depending on the hook being executed, Git may provide additional \ - arguments, such as the commit message file for `commit-msg` hook. \ - These will be passed along in order." + Depending on the hook being executed, Git might provide\n\ + additional arguments at runtime (e.g., a commit message\n\ + file to `commit-msg`). These are passed as-is, in order." ): Vec, /// Enable verbose output during task execution. verbose(long, short = 'v'): bool, @@ -233,19 +259,7 @@ cli! { spec( required = true, last = true, - long_help = "Task specification to associate with the hook.\n\n\ - Task specifications can take on several different forms:\n \ - 1. a raw shell command string (e.g. `\"git add -A\"`)\n \ - 2. a task name from the configuration file, which must either be:\n \ - - defined in the `tasks` section of a deno.json file, or ...\n \ - - defined in the `scripts` section of a package.json file\n \ - 3. an object with `command`, `dependencies`, and/or `description` fields, where:\n \ - - `command` is a shell command string to execute,\n \ - - `dependencies` is an array of tasks to run before the command,\n \ - Note: this field is required if `command` is not provided.\n \ - - `description` is a human-readable summary of the task (optional)\n \ - 4. a sequence where value satisfies either type 1, 2, or 3 above.\n \ - Multiple specifications can be provided to build a sequence." + long_help = TASK_SPEC_LONG_HELP ): Vec, /// Replace any existing hook definition instead of appending to it. replace(long, short = 'r'): bool, @@ -275,19 +289,7 @@ cli! { spec( required = true, last = true, - long_help = "New task specification to associate with the hook.\n\n\ - Task specifications can take on several different forms:\n \ - 1. a raw shell command string (e.g. `\"git add -A\"`)\n \ - 2. a task name from the configuration file, which must either be:\n \ - - defined in the `tasks` section of a deno.json file, or ...\n \ - - defined in the `scripts` section of a package.json file\n \ - 3. an object with `command`, `dependencies`, and/or `description` fields, where:\n \ - - `command` is a shell command string to execute,\n \ - - `dependencies` is an array of tasks to run before the command,\n \ - Note: this field is required if `command` is not provided.\n \ - - `description` is a human-readable summary of the task (optional)\n \ - 4. a sequence where value satisfies either type 1, 2, or 3 above.\n \ - Multiple specifications can be provided to build a sequence." + long_help = TASK_SPEC_LONG_HELP ): Vec, /// Replace the existing hook definition instead of appending to it. replace(long, short = 'r'): bool, diff --git a/src/config.rs b/src/config.rs index 4aeca71..f5bf63d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,26 +1,29 @@ //! Configuration discovery and parsing. //! //! This module contains logic for locating and parsing configuration files -//! that define hooks and tasks. The utility searches for a `deno.json` or -//! `deno.jsonc` file first; if none is found it will fall back to a -//! `package.json` file. The chosen file is inspected for a top-level +//! that define hooks and tasks. The utility prefers a `deno.json` or +//! `deno.jsonc` file, but will fall back to `package.json` when no hooks are +//! present in the Deno config. The chosen file is inspected for a top-level //! `hooks` object mapping Git hook names to task specifications. In //! addition, the Node `scripts` field and Deno `tasks` field are captured //! so that tasks can reference them. -use crate::constants::GIT_HOOKS; -use crate::handlers::RunnerError; -use crate::task::TaskSpec; -use crate::task::TaskSpecParseError; -use derive_more::IsVariant; -use serde_json::Value; -use serde_json::{self}; use std::collections::HashMap; use std::fs; use std::path::Path; use std::path::PathBuf; + +use derive_more::IsVariant; +use moos::CowStr; +use serde_json::Value; +use serde_json::{self}; use thiserror::Error; +use crate::constants::GIT_HOOKS; +use crate::handlers::RunnerError; +use crate::task::TaskSpec; +use crate::task::TaskSpecParseError; + /// A resolved configuration containing hook definitions and tasks. #[derive(Debug, Clone)] pub struct HookConfig { @@ -30,16 +33,37 @@ pub struct HookConfig { pub source: ConfigSource, /// Mapping of hook names (e.g. "pre-commit") to their task specification. pub hooks: HashMap, - /// Mapping of task names to raw commands coming from the Node `scripts` + /// Mapping of task names to script commands coming from the Node `scripts` /// field. - pub node_scripts: HashMap, - /// Mapping of task names to raw commands coming from the Deno `tasks` field. - pub deno_tasks: HashMap, + pub node_scripts: HashMap>, + /// Mapping of task names to Deno task specifications from the `tasks` field. + pub deno_tasks: HashMap, /// The preferred package manager to use when executing Node scripts (npm, /// pnpm, yarn, etc.). pub package_manager: Option, } +impl<'a> HookConfig +where + Self: 'a, +{ + pub(crate) fn tasks(&'a self) -> Vec<(CowStr<'a>, TaskSpec)> { + let mut specs: Vec<(CowStr<'a>, TaskSpec)> = vec![]; + + for (s, t) in &self.deno_tasks { + specs.push((s.as_str().into(), t.clone())); + } + + for (s, t) in &self.node_scripts { + let spec = TaskSpec::Single(t.clone()); + specs.push((s.as_str().into(), spec)) + } + + specs.sort_by_key(|(k, _)| (*k).to_ascii_lowercase()); + specs + } +} + /// Enum describing where the configuration was loaded from. #[derive(Debug, Clone, IsVariant)] pub enum ConfigSource { @@ -124,6 +148,9 @@ pub enum ConfigError { /// The hooks field exists but could not be parsed into a task specification. #[error("invalid hook definition for '{0}': {1}")] InvalidHook(String, #[source] TaskSpecParseError), + /// A Deno task definition could not be parsed or validated. + #[error("invalid task definition for '{0}': {1}")] + InvalidTask(String, String), /// An unknown or unsupported Git hook name was specified. #[error("unknown Git hook name '{0}'. Supported hooks are: {supported_hooks}", supported_hooks = GIT_HOOKS.join(", "))] UnknownHook(String), @@ -131,18 +158,36 @@ pub enum ConfigError { impl HookConfig { /// Discover and load a configuration from the specified directory. The search - /// order is `deno.json`, `deno.jsonc`, then `package.json`. If none of - /// these exist, returns [`ConfigError::NotFound`]. + /// order is `deno.json`, `deno.jsonc`, then `package.json`, but if a Deno + /// config exists without hooks and a package.json with hooks is present, the + /// package.json is preferred. If none of these exist, returns + /// [`ConfigError::NotFound`]. pub fn discover(dir: &Path) -> Result { let deno_json = dir.join("deno.json"); let deno_jsonc = dir.join("deno.jsonc"); let package_json = dir.join("package.json"); - if deno_json.exists() { - Self::load_deno_json(&deno_json) + let deno_path = if deno_json.exists() { + Some(deno_json) } else if deno_jsonc.exists() { - Self::load_deno_json(&deno_jsonc) - } else if package_json.exists() { + Some(deno_jsonc) + } else { + None + }; + + if let Some(deno_path) = deno_path { + let deno_has_hooks = Self::config_has_hooks(&deno_path, true)?; + if deno_has_hooks { + return Self::load_deno_json(&deno_path); + } + if package_json.exists() && Self::config_has_hooks(&package_json, false)? + { + return Self::load_package_json(&package_json); + } + return Self::load_deno_json(&deno_path); + } + + if package_json.exists() { Self::load_package_json(&package_json) } else { Err(ConfigError::NotFound(dir.to_path_buf())) @@ -175,35 +220,15 @@ impl HookConfig { } } } - // Extract deno tasks (these are simple command strings in Deno). + // Extract deno tasks (strings or structured TaskSpec objects). let mut deno_tasks = HashMap::new(); if let Some(Value::Object(tasks)) = value.get("tasks") { for (name, val) in tasks { - match val { - Value::String(cmd) => { - deno_tasks.insert(name.clone(), cmd.clone()); - } - // Deno tasks may also be objects with command/description etc. - Value::Object(obj) => { - let mut cmd_parts = Vec::new(); - if let Some(Value::Array(deps)) = obj.get("dependencies") { - // If only dependencies are defined, we can join them with "&&". - for dep in deps { - if let Value::String(task) = dep { - cmd_parts.push(format!("deno task {task}")); - } - } - } - if let Some(Value::String(cmd)) = obj.get("command") { - cmd_parts.push(cmd.clone()); - } - let joined = cmd_parts.join(" && "); - deno_tasks.insert(name.clone(), joined); - } - _ => {} - } + let spec = Self::parse_deno_task(name, val)?; + deno_tasks.insert(name.clone(), spec); } } + Self::validate_deno_task_dependencies(&deno_tasks)?; Ok(HookConfig { source: ConfigSource::DenoJson(path.to_path_buf()), hooks, @@ -242,7 +267,7 @@ impl HookConfig { if let Some(Value::Object(scripts)) = value.get("scripts") { for (name, val) in scripts { if let Value::String(cmd) = val { - node_scripts.insert(name.clone(), cmd.clone()); + node_scripts.insert(name.clone(), CowStr::from(cmd.clone())); } } } @@ -261,6 +286,56 @@ impl HookConfig { package_manager, }) } + + fn config_has_hooks(path: &Path, is_deno: bool) -> Result { + let content = fs::read_to_string(path) + .map_err(|e| ConfigError::Io(path.to_path_buf(), e))?; + let content = if is_deno { + strip_json_comments(&content) + } else { + content + }; + let value: Value = serde_json::from_str(&content) + .map_err(|e| ConfigError::Json(path.to_path_buf(), e))?; + Ok(matches!(value.get("hooks"), Some(Value::Object(_)))) + } + + fn parse_deno_task( + name: &str, + value: &Value, + ) -> Result { + if let Value::Object(map) = value + && let Some(deps_value) = + map.get("dependencies").or_else(|| map.get("depends")) + && !matches!(deps_value, Value::Array(_)) + { + return Err(ConfigError::InvalidTask( + name.to_string(), + "dependencies must be an array of strings".into(), + )); + } + TaskSpec::from_json(value).map_err(|err| { + ConfigError::InvalidTask(name.to_string(), err.to_string()) + }) + } + + fn validate_deno_task_dependencies( + tasks: &HashMap, + ) -> Result<(), ConfigError> { + for (name, spec) in tasks { + if let TaskSpec::Detailed { dependencies, .. } = spec { + for dep in dependencies { + if !tasks.contains_key(dep.as_ref()) { + return Err(ConfigError::InvalidTask( + name.clone(), + format!("dependency '{dep}' is not defined in tasks"), + )); + } + } + } + } + Ok(()) + } } /// Remove JavaScript-style comments from a JSON string. @@ -336,7 +411,7 @@ pub(crate) fn parse_spec_input(spec: &str) -> Result { let value: Value = serde_json::from_str(trimmed)?; TaskSpec::from_json(&value).map_err(RunnerError::InvalidTaskSpec) } else { - Ok(TaskSpec::Single(trimmed.to_string())) + Ok(TaskSpec::Single(CowStr::from(trimmed.to_string()))) } } diff --git a/src/macros.rs b/src/macros.rs index 155d975..03ffdbc 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -51,11 +51,11 @@ macro_rules! print_tasks { while n > 0 { let name = &all_tasks[all_tasks.len() - n]; let cmd = if let Some(script) = $cfg.node_scripts.get(*name) { - script - } else if let Some(script) = $cfg.deno_tasks.get(*name) { - script + script.to_string() + } else if let Some(task) = $cfg.deno_tasks.get(*name) { + task.command_string() } else { - "" + "".into() }; let named = (*name).clone(); let cmd = cmd.replace('\n', " "); diff --git a/src/runner.rs b/src/runner.rs index c71ded9..c9673e9 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -10,11 +10,11 @@ use std::io; use std::process::Command; use std::process::ExitStatus; -use ::derive_more::IsVariant; -use ::serde_json::json; +use derive_more::IsVariant; use moos::CowStr; use serde::Serialize; use serde_json::Value; +use serde_json::json; use thiserror::Error; use crate::GIT_HOOKS; @@ -105,7 +105,7 @@ pub fn handle_list(opts: &ListOpts) -> Result<(), RunnerError> { (Name(a), Name(b)) => a.cmp(b), // so we can safely discard all other cases (_, _) => std::cmp::Ordering::Equal, - }) + }); } else { hooks = hooks_sorted .iter() @@ -114,7 +114,7 @@ pub fn handle_list(opts: &ListOpts) -> Result<(), RunnerError> { spec: if opts.name_only { None } else { - Some(spec.to_json()) + Some(spec.to_json_value()) }, }) .collect(); @@ -178,12 +178,11 @@ pub fn handle_list(opts: &ListOpts) -> Result<(), RunnerError> { eprintln!("Discovered {n} hook{s} in '{path}':"); eprintln!(); } - let mut i = 0; - for (hook, spec) in hooks_sorted { - if i != 0 && !opts.all && !opts.compact && !opts.name_only { + + for (i, (hook, spec)) in hooks_sorted.iter().enumerate() { + if i > 0 && !opts.all && !opts.compact && !opts.name_only { eprintln!(); } - i += 1; if opts.name_only || opts.all { println!("- {hook}"); continue; @@ -230,14 +229,13 @@ pub fn handle_list(opts: &ListOpts) -> Result<(), RunnerError> { let info = if cfg.node_scripts.contains_key(&rest) || cfg.deno_tasks.contains_key(&rest) { - let (_kind, cmd): (CowStr, CowStr) = - if let Some(script) = cfg.node_scripts.get(&rest) { - (CowStr::from("script"), CowStr::from(script.as_str())) - } else if let Some(script) = cfg.deno_tasks.get(&rest) { - (CowStr::from("task"), CowStr::from(script.as_str())) - } else { - (CowStr::from("unknown"), "".into()) - }; + let cmd = if let Some(script) = cfg.node_scripts.get(&rest) { + script.to_string() + } else if let Some(task) = cfg.deno_tasks.get(&rest) { + task.command_string() + } else { + "".into() + }; let named = rest.clone(); let cmd = cmd.replace('\n', " "); format!( @@ -332,14 +330,14 @@ pub fn handle_task(opts: &TaskOpts) -> Result<(), RunnerError> { .iter() .map(|(name, cmd)| TaskEntry { name: name.clone(), - command: cmd.clone(), + command: cmd.command_string(), kind: "task".into(), }) .collect(); tasks.extend(cfg.node_scripts.iter().map(|(name, cmd)| TaskEntry { name: name.clone(), - command: cmd.clone(), + command: cmd.to_string(), kind: "script".into(), })); @@ -413,7 +411,7 @@ pub fn handle_add(opts: &AddOpts) -> Result<(), RunnerError> { let merged = merge_specs(cfg.hooks.get(&opts.hook), spec, opts.replace); mutate_hooks(&cfg, |hooks| { - hooks.insert(opts.hook.clone(), merged.to_json()); + hooks.insert(opts.hook.clone(), merged.to_json_value()); Ok(()) })?; @@ -462,17 +460,20 @@ pub fn handle_remove(opts: &RemoveOpts) -> Result<(), RunnerError> { mutate_hooks(&cfg, |hooks| { if let Some(task_str) = &opts.task { let target = parse_spec_input(task_str)?; + if let Some(current) = hooks.get(&opts.hook).cloned() { let parsed_current = TaskSpec::from_json(¤t) .map_err(RunnerError::InvalidTaskSpec)?; + if let Some(next_spec) = remove_task_from_spec(&parsed_current, &target) { - hooks.insert(opts.hook.clone(), next_spec.to_json()); + hooks.insert(opts.hook.clone(), next_spec.to_json_value()); } else { hooks.remove(&opts.hook); } removed = true; } + Ok(()) } else { hooks.remove(&opts.hook); @@ -494,6 +495,7 @@ pub fn handle_remove(opts: &RemoveOpts) -> Result<(), RunnerError> { } else if !opts.force { eprintln!("Task not found in hook '{}'; no changes made.", opts.hook); } + Ok(()) } @@ -501,6 +503,7 @@ pub fn handle_remove(opts: &RemoveOpts) -> Result<(), RunnerError> { pub fn handle_update(opts: &UpdateOpts) -> Result<(), RunnerError> { let cfg = HookConfig::discover(&std::env::current_dir()?)?; ensure_valid_hook_name(&opts.hook)?; + if !cfg.hooks.contains_key(&opts.hook) { eprintln!( "Hook '{}' is not currently defined in {}. Use `huk add` to create it.", @@ -511,13 +514,16 @@ pub fn handle_update(opts: &UpdateOpts) -> Result<(), RunnerError> { } let spec = parse_specs_inputs(&opts.spec)?; + let merged = merge_specs(cfg.hooks.get(&opts.hook), spec, opts.replace); + mutate_hooks(&cfg, |hooks| { - hooks.insert(opts.hook.clone(), merged.to_json()); + hooks.insert(opts.hook.clone(), merged.to_json_value()); Ok(()) })?; let verb = if opts.replace { "Replaced" } else { "Updated" }; + eprintln!("{verb} hook '{}' in {}.", opts.hook, cfg.source.as_str()); Ok(()) } @@ -562,7 +568,7 @@ impl<'cfg> TaskRunner<'cfg> { extra_args: &[String], ) -> Result<(), RunnerError> { match spec { - TaskSpec::Single(name) => self.run_single(name, extra_args), + TaskSpec::Single(name) => self.run_single(name.as_ref(), extra_args), TaskSpec::Detailed { command, dependencies, @@ -570,9 +576,10 @@ impl<'cfg> TaskRunner<'cfg> { } => { // Execute dependencies first. for dep in dependencies { - self.run_named_task(dep)?; + self.run_named_task(dep.as_ref())?; } - if let Some(cmd) = command { + + if let Some(cmd) = command.as_deref() { self.exec_raw_command(cmd, extra_args) } else { // Only dependencies defined; nothing else to do. @@ -594,25 +601,22 @@ impl<'cfg> TaskRunner<'cfg> { name: &str, extra_args: &[String], ) -> Result<(), RunnerError> { - // To avoid cycles, track the task names we are resolving. if self.visiting.contains(name) { return Err(RunnerError::CircularDependency(name.to_string())); } self.visiting.insert(name.to_string()); let result = if self.config.deno_tasks.get(name).is_some() { - // It's a Deno task. self.exec_deno_task(name, extra_args) - } else if let Some(script) = self.config.node_scripts.get(name) { - // It's a Node script. - self.exec_node_script(name, script, extra_args) + } else if self.config.node_scripts.get(name).is_some() { + self.exec_node_script(name, extra_args) } else if let Some(spec) = self.config.hooks.get(name) { - // It's another hook; run its spec. self.run_spec(spec, name, extra_args) } else { - // Unknown: treat as raw command. self.exec_raw_command(name, extra_args) }; + self.visiting.remove(name); + result } @@ -631,24 +635,32 @@ impl<'cfg> TaskRunner<'cfg> { cmd: &str, extra_args: &[String], ) -> Result<(), RunnerError> { - // Compose the final command string. If there are extra args, append them. let mut full_cmd = cmd.to_string(); + if !extra_args.is_empty() { - // Append each argument quoting as necessary (naive quoting: wrap in - // single quotes if whitespace). for arg in extra_args { + full_cmd.push(' '); + if arg.contains(' ') { - full_cmd.push(' '); - full_cmd.push_str(&format!("'{}'", arg.replace('"', "\\\""))); + let arg = arg.replace('\'', "\\\'").replace('"', "'\"'"); + full_cmd.push_str(&format!("'{arg}'")); } else { - full_cmd.push(' '); full_cmd.push_str(arg); } } } - // Execute via sh -c. + + // todo: fix this and correctly implement windows support throughout the + // rest of the crate. + #[cfg(target_os = "windows")] + let mut command: Command = Command::new("pwsh.exe"); + + #[cfg(not(target_os = "windows"))] + // todo: 'sh' should not be hardcoded here. the shell/program used as a + // runner should be configurable let mut command = Command::new("sh"); command.arg("-c").arg(&full_cmd); + self.spawn_command(command, full_cmd) } @@ -660,9 +672,11 @@ impl<'cfg> TaskRunner<'cfg> { ) -> Result<(), RunnerError> { let mut cmd = Command::new("deno"); cmd.arg("task").arg(name); + for arg in extra_args { cmd.arg(arg); } + self.spawn_command(cmd, format!("deno task {name}")) } @@ -670,16 +684,16 @@ impl<'cfg> TaskRunner<'cfg> { pub(crate) fn exec_node_script( &mut self, name: &str, - _script: &str, extra_args: &[String], ) -> Result<(), RunnerError> { - // Determine the package manager. Parse something like "pnpm@7.1.2" into - // "pnpm". + // Determine the package manager. Parses e.g. "pnpm@7.1.2" into "pnpm" let manager = self.config.package_manager.as_deref().unwrap_or("npm"); let exe_name = Self::extract_package_manager_command(manager); + // Build the command: run