diff --git a/crates/vite_task/src/lib.rs b/crates/vite_task/src/lib.rs index a20687c3..6f473cb5 100644 --- a/crates/vite_task/src/lib.rs +++ b/crates/vite_task/src/lib.rs @@ -5,7 +5,9 @@ pub mod session; // Public exports for vite_task_bin pub use cli::{CacheSubcommand, Command, RunCommand, RunFlags}; -pub use session::{CommandHandler, ExitStatus, HandledCommand, Session, SessionConfig}; +pub use session::{ + CommandHandler, ExitStatus, HandledCommand, Session, SessionConfig, TaskErrorHints, +}; pub use vite_task_graph::{ config::{ self, diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index c378794f..abeb11da 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -4,12 +4,18 @@ mod execute; pub(crate) mod reporter; // Re-export types that are part of the public API -use std::{ffi::OsStr, fmt::Debug, io::IsTerminal, sync::Arc}; +use std::{ + ffi::OsStr, + fmt::Debug, + io::{IsTerminal, Write as _}, + sync::Arc, +}; use cache::ExecutionCache; pub use cache::{CacheMiss, FingerprintMismatch}; use clap::Parser as _; use once_cell::sync::OnceCell; +use owo_colors::{OwoColorize as _, Stream, Style}; pub use reporter::ExitStatus; use reporter::{ GroupedReporterBuilder, InterleavedReporterBuilder, LabeledReporterBuilder, @@ -80,6 +86,11 @@ impl TaskGraphLoader for LazyTaskGraph<'_> { } } +pub struct TaskErrorHints { + pub task_list_hint: Str, + pub task_source_hint: Str, +} + pub struct SessionConfig<'a> { pub command_handler: &'a mut (dyn CommandHandler + 'a), pub user_config_loader: &'a mut (dyn UserConfigLoader + 'a), @@ -110,6 +121,9 @@ pub trait CommandHandler: Debug { &mut self, command: &mut ScriptCommand, ) -> anyhow::Result; + + /// Returns hints shown to the user when a task is not found. + fn task_error_hints(&self) -> TaskErrorHints; } #[derive(derive_more::Debug)] @@ -165,6 +179,7 @@ pub struct Session<'a> { plan_request_parser: PlanRequestParser<'a>, program_name: Str, + task_error_hints: TaskErrorHints, /// Cache is lazily initialized to avoid `SQLite` race conditions when multiple /// processes (e.g., parallel `vt lib` commands) start simultaneously. @@ -227,6 +242,7 @@ impl<'a> Session<'a> { prepend_path_env(&mut envs, &workspace_node_modules_bin)?; // Cache is lazily initialized on first access to avoid SQLite race conditions + let task_error_hints = config.command_handler.task_error_hints(); Ok(Self { workspace_path: Arc::clone(&workspace_root.path), lazy_task_graph: LazyTaskGraph::Uninitialized { @@ -237,6 +253,7 @@ impl<'a> Session<'a> { cwd, plan_request_parser: PlanRequestParser { command_handler: config.command_handler }, program_name: config.program_name, + task_error_hints, cache: OnceCell::new(), cache_path, }) @@ -246,13 +263,17 @@ impl<'a> Session<'a> { /// /// # Errors /// - /// Returns an error if planning or execution fails. + /// Prints user-facing CLI errors and returns an exit status. #[tracing::instrument(level = "debug", skip_all)] - pub async fn main(mut self, command: Command) -> anyhow::Result { + pub async fn main(mut self, command: Command) -> ExitStatus { + let verbose = matches!(&command, Command::Run(run) if run.flags.verbose); match self.main_inner(command).await { - Ok(()) => Ok(ExitStatus::SUCCESS), - Err(SessionError::EarlyExit(status)) => Ok(status), - Err(SessionError::Anyhow(err)) => Err(err), + Ok(()) => ExitStatus::SUCCESS, + Err(SessionError::EarlyExit(status)) => status, + Err(SessionError::Anyhow(err)) => { + self.print_cli_error(&err, verbose); + ExitStatus::FAILURE + } } } @@ -343,6 +364,87 @@ impl<'a> Session<'a> { Ok(()) } + fn print_cli_error(&self, err: &anyhow::Error, verbose: bool) { + let mut stderr = std::io::stderr(); + + if let Some(plan_error) = err.downcast_ref::() { + if let Some(nested) = plan_error.nested_missing_task() { + let _ = writeln!( + stderr, + "{}", + vite_str::format!("Task \"{}\" not found.", nested.task_name) + .if_supports_color(Stream::Stderr, |text| { + text.style(Style::new().red().bold()) + }) + ); + let _ = writeln!( + stderr, + "{} depends on \"{}\" via `{}`.", + nested.parent_task, nested.task_name, nested.command + ); + let _ = writeln!(stderr, "Next steps:"); + let _ = writeln!(stderr, " {}", self.task_error_hints.task_list_hint); + let _ = writeln!(stderr, " {}", self.task_error_hints.task_source_hint); + if verbose { + let _ = writeln!(stderr); + let _ = writeln!( + stderr, + "{}", + "Raw details:".if_supports_color(Stream::Stderr, |text| text.bold()) + ); + let _ = writeln!(stderr, "{err:#}"); + let _ = writeln!(stderr, "Exit code: {}", ExitStatus::FAILURE.0); + } + let _ = stderr.flush(); + return; + } + + if let vite_task_plan::Error::NoTasksMatched(task_name) = plan_error { + let _ = writeln!( + stderr, + "{}", + vite_str::format!("Task \"{}\" not found.", task_name) + .if_supports_color(Stream::Stderr, |text| text + .style(Style::new().red().bold())) + ); + let _ = writeln!(stderr, "Next steps:"); + let _ = writeln!(stderr, " {}", self.task_error_hints.task_list_hint); + let _ = writeln!(stderr, " {}", self.task_error_hints.task_source_hint); + if verbose { + let _ = writeln!(stderr); + let _ = writeln!( + stderr, + "{}", + "Raw details:".if_supports_color(Stream::Stderr, |text| text.bold()) + ); + let _ = writeln!(stderr, "{err:#}"); + let _ = writeln!(stderr, "Exit code: {}", ExitStatus::FAILURE.0); + } + let _ = stderr.flush(); + return; + } + } + + let _ = writeln!( + stderr, + "{} {}", + "✗".if_supports_color(Stream::Stderr, |text| text.style(Style::new().red().bold())), + err.to_string() + .if_supports_color(Stream::Stderr, |text| text.style(Style::new().red())) + ); + if verbose { + let _ = writeln!(stderr); + let _ = writeln!( + stderr, + "{}", + "Raw details:".if_supports_color(Stream::Stderr, |text| text.bold()) + ); + let _ = writeln!(stderr, "{err:#}"); + let _ = writeln!(stderr, "Exit code: {}", ExitStatus::FAILURE.0); + } + let _ = stderr.flush(); + } + /// Show the task selector or list, and return a plan request for the selected task. /// /// In interactive mode, shows a fuzzy-searchable selection list. On selection, diff --git a/crates/vite_task_bin/src/lib.rs b/crates/vite_task_bin/src/lib.rs index 32dc72f3..dc495ea0 100644 --- a/crates/vite_task_bin/src/lib.rs +++ b/crates/vite_task_bin/src/lib.rs @@ -9,8 +9,8 @@ use clap::Parser; use vite_path::AbsolutePath; use vite_str::Str; use vite_task::{ - Command, EnabledCacheConfig, HandledCommand, ScriptCommand, SessionConfig, UserCacheConfig, - get_path_env, plan_request::SyntheticPlanRequest, + Command, EnabledCacheConfig, HandledCommand, ScriptCommand, SessionConfig, TaskErrorHints, + UserCacheConfig, get_path_env, plan_request::SyntheticPlanRequest, }; #[derive(Debug, Default)] @@ -105,6 +105,15 @@ impl vite_task::CommandHandler for CommandHandler { Args::Task(parsed) => Ok(HandledCommand::ViteTaskCommand(parsed)), } } + + fn task_error_hints(&self) -> TaskErrorHints { + TaskErrorHints { + task_list_hint: Str::from("Run `vt run` to browse available tasks."), + task_source_hint: Str::from( + "Check `vite-task.json` and `package.json` scripts where tasks are defined.", + ), + } + } } /// A `UserConfigLoader` implementation that only loads `vite-task.json`. diff --git a/crates/vite_task_bin/src/main.rs b/crates/vite_task_bin/src/main.rs index a0569897..e268d6f8 100644 --- a/crates/vite_task_bin/src/main.rs +++ b/crates/vite_task_bin/src/main.rs @@ -14,5 +14,5 @@ async fn run() -> anyhow::Result { let args = Command::parse(); let mut owned_config = OwnedSessionConfig::default(); let session = Session::init(owned_config.as_config())?; - session.main(args).await + Ok(session.main(args).await) } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/error_cycle_dependency/snapshots/cycle dependency error.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/error_cycle_dependency/snapshots/cycle dependency error.snap index 94bfc0dd..1762fe65 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/error_cycle_dependency/snapshots/cycle dependency error.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/error_cycle_dependency/snapshots/cycle dependency error.snap @@ -3,4 +3,4 @@ source: crates/vite_task_bin/tests/e2e_snapshots/main.rs expression: e2e_outputs --- [1]> vt run task-a # task-a -> task-b -> task-a cycle -Error: Cycle dependency detected: error-cycle-dependency-test#task-a -> error-cycle-dependency-test#task-b -> error-cycle-dependency-test#task-a +✗ Cycle dependency detected: error-cycle-dependency-test#task-a -> error-cycle-dependency-test#task-b -> error-cycle-dependency-test#task-a diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/nested-task-not-found/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/nested-task-not-found/package.json new file mode 100644 index 00000000..9d59d9eb --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/nested-task-not-found/package.json @@ -0,0 +1,5 @@ +{ + "name": "nested-task-not-found-test", + "private": true, + "workspaces": [] +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/nested-task-not-found/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/nested-task-not-found/snapshots.toml new file mode 100644 index 00000000..e432bfad --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/nested-task-not-found/snapshots.toml @@ -0,0 +1,7 @@ +[[e2e]] +name = "nested task not found" +steps = ["vt run repro"] + +[[e2e]] +name = "nested task not found verbose" +steps = ["vt run repro --verbose"] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/nested-task-not-found/snapshots/nested task not found verbose.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/nested-task-not-found/snapshots/nested task not found verbose.snap new file mode 100644 index 00000000..eae0d8fe --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/nested-task-not-found/snapshots/nested task not found verbose.snap @@ -0,0 +1,10 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +[1]> vt run repro --verbose +Task "missing-task" not found. +nested-task-not-found-test#repro depends on "missing-task" via `vt run missing-task`. +Next steps: + Run `vt run` to browse available tasks. + Check `vite-task.json` and `package.json` scripts where tasks are defined. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/nested-task-not-found/snapshots/nested task not found.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/nested-task-not-found/snapshots/nested task not found.snap new file mode 100644 index 00000000..691d462b --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/nested-task-not-found/snapshots/nested task not found.snap @@ -0,0 +1,10 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +[1]> vt run repro +Task "missing-task" not found. +nested-task-not-found-test#repro depends on "missing-task" via `vt run missing-task`. +Next steps: + Run `vt run` to browse available tasks. + Check `vite-task.json` and `package.json` scripts where tasks are defined. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/nested-task-not-found/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/nested-task-not-found/vite-task.json new file mode 100644 index 00000000..277e0b62 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/nested-task-not-found/vite-task.json @@ -0,0 +1,7 @@ +{ + "tasks": { + "repro": { + "command": "vt run missing-task" + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive recursive typo errors.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive recursive typo errors.snap index bcc3c8ec..32424571 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive recursive typo errors.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive recursive typo errors.snap @@ -3,4 +3,7 @@ source: crates/vite_task_bin/tests/e2e_snapshots/main.rs expression: e2e_outputs --- [1]> echo '' | vt run -r buid -Error: Task "buid" not found +Task "buid" not found. +Next steps: + Run `vt run` to browse available tasks. + Check `vite-task.json` and `package.json` scripts where tasks are defined. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/recursive without task errors.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/recursive without task errors.snap index cfa41815..8a6cb58d 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/recursive without task errors.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/recursive without task errors.snap @@ -5,4 +5,4 @@ info: cwd: packages/app --- [1]> vt run -r -Error: No task specifier provided for 'run' command +✗ No task specifier provided for 'run' command diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/transitive typo errors.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/transitive typo errors.snap index 246cdb0a..210a44d2 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/transitive typo errors.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/transitive typo errors.snap @@ -5,4 +5,7 @@ info: cwd: packages/app --- [1]> vt run -t buid -Error: Task "buid" not found +Task "buid" not found. +Next steps: + Run `vt run` to browse available tasks. + Check `vite-task.json` and `package.json` scripts where tasks are defined. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/typo in task script fails without list.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/typo in task script fails without list.snap index 271b5f50..6acbdd4a 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/typo in task script fails without list.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/typo in task script fails without list.snap @@ -3,7 +3,8 @@ source: crates/vite_task_bin/tests/e2e_snapshots/main.rs expression: e2e_outputs --- [1]> vt run run-typo-task -Error: Failed to plan tasks from `vt run nonexistent-xyz` in task task-select-test#run-typo-task - -Caused by: - Task "nonexistent-xyz" not found +Task "nonexistent-xyz" not found. +task-select-test#run-typo-task depends on "nonexistent-xyz" via `vt run nonexistent-xyz`. +Next steps: + Run `vt run` to browse available tasks. + Check `vite-task.json` and `package.json` scripts where tasks are defined. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/verbose without task errors.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/verbose without task errors.snap index 3de34734..65f72a8a 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/verbose without task errors.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/verbose without task errors.snap @@ -5,4 +5,8 @@ info: cwd: packages/app --- [1]> vt run --verbose -Error: No task specifier provided for 'run' command +✗ No task specifier provided for 'run' command + +Raw details: +No task specifier provided for 'run' command +Exit code: 1 diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs index 66db2783..626d9ffa 100644 --- a/crates/vite_task_plan/src/error.rs +++ b/crates/vite_task_plan/src/error.rs @@ -11,6 +11,13 @@ use vite_task_graph::display::TaskDisplay; use crate::{context::TaskRecursionError, envs::ResolveEnvError}; +#[derive(Debug, Clone)] +pub struct NestedMissingTaskError<'a> { + pub parent_task: &'a TaskDisplay, + pub command: &'a Str, + pub task_name: &'a Str, +} + #[derive(Debug, thiserror::Error)] pub enum CdCommandError { #[error("No home directory found for 'cd' command with no arguments")] @@ -162,3 +169,18 @@ pub enum Error { #[error("Cycle dependency detected: {}", _0.iter().map(std::string::ToString::to_string).collect::>().join(" -> "))] CycleDependencyDetected(Vec), } + +impl Error { + #[must_use] + pub fn nested_missing_task(&self) -> Option> { + match self { + Self::NestPlan { task_display, command, error } => match error.as_ref() { + Self::NoTasksMatched(task_name) => { + Some(NestedMissingTaskError { parent_task: task_display, command, task_name }) + } + inner => inner.nested_missing_task(), + }, + _ => None, + } + } +}