From e46e865e5585142993bfb0562e4fa9026e5b86b6 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 17:16:58 +0800 Subject: [PATCH 1/8] feat: restrict interactive task selector to bare `vp run` and cwd-only Co-Authored-By: Claude Opus 4.6 --- crates/vite_task/src/cli/mod.rs | 27 ++++---- crates/vite_task/src/session/mod.rs | 68 +++++++++++++------ .../task-list/snapshots/vp run in script.snap | 1 + .../interactive long command truncated.snap | 1 + .../fixtures/task-select/snapshots.toml | 32 ++++++--- ...ve enter with no results does nothing.snap | 1 + .../interactive escape clears query.snap | 1 + .../interactive scroll long list.snap | 1 + ...interactive search other package task.snap | 1 + ...earch preserves rating within package.snap | 1 + .../interactive search then select.snap | 1 + ...active search with hash skips reorder.snap | 1 + .../interactive select task from lib.snap | 1 + .../snapshots/interactive select task.snap | 1 + .../interactive select with recursive.snap | 31 --------- ...ctive select with typo and transitive.snap | 21 ------ .../interactive select with typo.snap | 1 + ...on-interactive recursive typo errors.snap} | 4 +- .../recursive without task errors.snap | 8 +++ .../snapshots/transitive typo errors.snap | 8 +++ ...ypo in task script fails without list.snap | 2 +- .../verbose with typo enters selector.snap | 30 ++++++++ .../verbose without task errors.snap | 8 +++ crates/vite_task_plan/src/error.rs | 4 +- crates/vite_task_plan/src/plan.rs | 4 +- crates/vite_workspace/src/package_filter.rs | 2 +- 26 files changed, 162 insertions(+), 99 deletions(-) delete mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with recursive.snap delete mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo and transitive.snap rename crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/{non-interactive did you mean with recursive.snap => non-interactive recursive typo errors.snap} (56%) create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/recursive without task errors.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/transitive typo errors.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/verbose with typo enters selector.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/verbose without task errors.snap diff --git a/crates/vite_task/src/cli/mod.rs b/crates/vite_task/src/cli/mod.rs index dca722db..2056c187 100644 --- a/crates/vite_task/src/cli/mod.rs +++ b/crates/vite_task/src/cli/mod.rs @@ -14,7 +14,7 @@ pub enum CacheSubcommand { } /// Flags that control how a `run` command selects tasks. -#[derive(Debug, Clone, clap::Args)] +#[derive(Debug, Clone, PartialEq, Eq, clap::Args)] pub struct RunFlags { #[clap(flatten)] pub package_query: PackageQueryArgs, @@ -36,7 +36,7 @@ pub struct RunFlags { /// /// Contains the `--last-details` flag which is resolved into a separate /// `ResolvedCommand::RunLastDetails` variant internally. -#[derive(Debug, clap::Args)] +#[derive(Debug, clap::Parser)] pub struct RunCommand { /// `packageName#taskName` or `taskName`. If omitted, lists all available tasks. pub(crate) task_specifier: Option, @@ -109,7 +109,7 @@ pub enum ResolvedCommand { /// /// Does not contain `last_details` — that case is represented by /// [`ResolvedCommand::RunLastDetails`] instead. -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] pub struct ResolvedRunCommand { /// `packageName#taskName` or `taskName`. If omitted, lists all available tasks. pub task_specifier: Option, @@ -151,21 +151,24 @@ impl ResolvedRunCommand { pub fn into_query_plan_request( self, cwd: &Arc, - ) -> Result { + ) -> Result<(QueryPlanRequest, bool), CLITaskQueryError> { let task_specifier = self.task_specifier.ok_or(CLITaskQueryError::MissingTaskSpecifier)?; - let (package_query, _is_cwd_only) = + let (package_query, is_cwd_only) = self.flags.package_query.into_package_query(task_specifier.package_name, cwd)?; let include_explicit_deps = !self.flags.ignore_depends_on; - Ok(QueryPlanRequest { - query: TaskQuery { - package_query, - task_name: task_specifier.task_name, - include_explicit_deps, + Ok(( + QueryPlanRequest { + query: TaskQuery { + package_query, + task_name: task_specifier.task_name, + include_explicit_deps, + }, + plan_options: PlanOptions { extra_args: self.additional_args.into() }, }, - plan_options: PlanOptions { extra_args: self.additional_args.into() }, - }) + is_cwd_only, + )) } } diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 6d7cd5ba..55d87377 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -8,6 +8,7 @@ use std::{ffi::OsStr, fmt::Debug, io::IsTerminal, sync::Arc}; use cache::ExecutionCache; pub use cache::{CacheMiss, FingerprintMismatch}; +use clap::Parser as _; use once_cell::sync::OnceCell; pub use reporter::ExitStatus; use reporter::{ @@ -109,7 +110,9 @@ impl vite_task_plan::PlanRequestParser for PlanRequestParser<'_> { } ResolvedCommand::Run(run_command) => { match run_command.into_query_plan_request(&command.cwd) { - Ok(query_plan_request) => Ok(Some(PlanRequest::Query(query_plan_request))), + Ok((query_plan_request, _)) => { + Ok(Some(PlanRequest::Query(query_plan_request))) + } Err(crate::cli::CLITaskQueryError::MissingTaskSpecifier) => { Ok(Some(PlanRequest::Synthetic( command.to_synthetic_plan_request(UserCacheConfig::disabled()), @@ -240,6 +243,10 @@ impl<'a> Session<'a> { let is_interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); + // Detect bare `vp run` (no task, no flags, no extra args) + let bare = RunCommand::parse_from(["vp"]).into_resolved(); + let is_bare = run_command == bare; + // Save task name and flags before consuming run_command let task_name = run_command.task_specifier.as_ref().map(|s| s.task_name.clone()); let show_details = run_command.flags.verbose; @@ -247,17 +254,23 @@ impl<'a> Session<'a> { let additional_args = run_command.additional_args.clone(); match self.plan_from_cli_run_resolved(cwd, run_command).await { - Ok(ref graph) if graph.node_count() == 0 => { - // No tasks matched the query — show task selector / "did you mean" - self.handle_no_task( - is_interactive, - task_name.as_deref(), - flags, - additional_args, - ) - .await + Ok((ref graph, is_cwd_only)) if graph.node_count() == 0 => { + if is_cwd_only { + self.handle_no_task( + is_interactive, + task_name.as_deref(), + flags, + additional_args, + ) + .await + } else { + return Err(vite_task_plan::Error::NoTasksMatched( + task_name.unwrap_or_default(), + ) + .into()); + } } - Ok(graph) => { + Ok((graph, _)) => { let builder = LabeledReporterBuilder::new( self.workspace_path(), Box::new(tokio::io::stdout()), @@ -271,7 +284,11 @@ impl<'a> Session<'a> { .unwrap_or(ExitStatus::SUCCESS)) } Err(err) if err.is_missing_task_specifier() => { - self.handle_no_task(is_interactive, None, flags, additional_args).await + if is_bare { + self.handle_no_task(is_interactive, None, flags, additional_args).await + } else { + return Err(vite_task_plan::Error::MissingTaskSpecifier.into()); + } } Err(err) => Err(err.into()), } @@ -387,8 +404,19 @@ impl<'a> Session<'a> { }; }; - // Interactive: run the selected task + // Interactive: print selected task and run it let selected_label = &select_items[selected_index].label; + { + use std::io::Write as _; + + use owo_colors::{OwoColorize as _, Stream}; + writeln!( + stdout, + "{}{}", + "Selected task: ".if_supports_color(Stream::Stdout, |s| s.bold()), + selected_label, + )?; + } let task_specifier = TaskSpecifier::parse_raw(selected_label); let show_details = flags.verbose; let run_command = RunCommand { @@ -578,7 +606,8 @@ impl<'a> Session<'a> { cwd: Arc, command: RunCommand, ) -> Result { - self.plan_from_cli_run_resolved(cwd, command.into_resolved()).await + let (graph, _) = self.plan_from_cli_run_resolved(cwd, command.into_resolved()).await?; + Ok(graph) } /// Internal: plans execution from a resolved run command. @@ -591,9 +620,9 @@ impl<'a> Session<'a> { &mut self, cwd: Arc, command: crate::cli::ResolvedRunCommand, - ) -> Result { - let query_plan_request = match command.into_query_plan_request(&cwd) { - Ok(query_plan_request) => query_plan_request, + ) -> Result<(ExecutionGraph, bool), vite_task_plan::Error> { + let (query_plan_request, is_cwd_only) = match command.into_query_plan_request(&cwd) { + Ok(result) => result, Err(crate::cli::CLITaskQueryError::MissingTaskSpecifier) => { return Err(vite_task_plan::Error::MissingTaskSpecifier); } @@ -606,7 +635,7 @@ impl<'a> Session<'a> { }); } }; - vite_task_plan::plan_query( + let graph = vite_task_plan::plan_query( query_plan_request, &self.workspace_path, &cwd, @@ -614,6 +643,7 @@ impl<'a> Session<'a> { &mut self.plan_request_parser, &mut self.lazy_task_graph, ) - .await + .await?; + Ok((graph, is_cwd_only)) } } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap index 3e933d32..15546912 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap @@ -14,5 +14,6 @@ Search task (↑/↓ to move, enter to select): lib#build: echo build lib @ write-key: enter $ vp run ⊘ cache disabled +Selected task: hello $ echo hello from root ⊘ cache disabled hello from root diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots/interactive long command truncated.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots/interactive long command truncated.snap index 7c021ed6..f1bd1d22 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots/interactive long command truncated.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots/interactive long command truncated.snap @@ -40,5 +40,6 @@ Search task (↑/↓ to move, enter to select): > long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… test: echo test app @ write-key: enter +Selected task: long-cmd ~/packages/app$ echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ⊘ cache disabled aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots.toml index ddea03ae..1d0fd7ed 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots.toml +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots.toml @@ -12,9 +12,9 @@ steps = [ "echo '' | vp run buid", ] -# Non-interactive: typo with -r flag +# Non-interactive: typo with -r flag errors (not cwd_only) [[e2e]] -name = "non-interactive did you mean with recursive" +name = "non-interactive recursive typo errors" steps = [ "echo '' | vp run -r buid", ] @@ -51,20 +51,20 @@ steps = [ { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write" = "lin" }, { "expect-milestone" = "task-select:lin:0" }, { "write-key" = "escape" }, { "expect-milestone" = "task-select::0" }, { "write-key" = "enter" }] }, ] -# Interactive: -r flag preserved +# -r flag without task errors (not bare) [[e2e]] -name = "interactive select with recursive" +name = "recursive without task errors" cwd = "packages/app" steps = [ - { command = "vp run -r", interactions = [{ "expect-milestone" = "task-select::0" }, { "write-key" = "enter" }] }, + "vp run -r", ] -# Interactive: -t flag + typo +# -t flag + typo errors (not cwd_only) [[e2e]] -name = "interactive select with typo and transitive" +name = "transitive typo errors" cwd = "packages/app" steps = [ - { command = "vp run -t buid", interactions = [{ "expect-milestone" = "task-select:buid:0" }, { "write-key" = "enter" }] }, + "vp run -t buid", ] # Interactive: scroll down past visible page, then select a task beyond the initial viewport @@ -131,3 +131,19 @@ name = "typo in task script fails without list" steps = [ "vp run run-typo-task", ] + +# --verbose without task: not bare, errors with "no task specifier provided" +[[e2e]] +name = "verbose without task errors" +cwd = "packages/app" +steps = [ + "vp run --verbose", +] + +# --verbose with typo: is_cwd_only is true, shows interactive selector +[[e2e]] +name = "verbose with typo enters selector" +cwd = "packages/app" +steps = [ + { command = "vp run buid --verbose", interactions = [{ "expect-milestone" = "task-select:buid:0" }, { "write-key" = "enter" }] }, +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive enter with no results does nothing.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive enter with no results does nothing.snap index f8fb9bf7..63568d19 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive enter with no results does nothing.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive enter with no results does nothing.snap @@ -42,5 +42,6 @@ Search task (↑/↓ to move, enter to select): task-select-test#format: echo format root (…3 more) @ write-key: enter +Selected task: build ~/packages/app$ echo build app ⊘ cache disabled build app diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive escape clears query.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive escape clears query.snap index a3169915..14e49503 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive escape clears query.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive escape clears query.snap @@ -42,5 +42,6 @@ Search task (↑/↓ to move, enter to select): task-select-test#format: echo format root (…3 more) @ write-key: enter +Selected task: build ~/packages/app$ echo build app ⊘ cache disabled build app diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap index 668f4a4a..ee81188a 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap @@ -67,5 +67,6 @@ Search task (↑/↓ to move, enter to select): task-select-test#format: echo format root (…3 more) @ write-key: enter +Selected task: build ~/packages/app$ echo build app ⊘ cache disabled build app diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search other package task.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search other package task.snap index 72975311..65655cc3 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search other package task.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search other package task.snap @@ -25,5 +25,6 @@ Search task (↑/↓ to move, enter to select): Search task (↑/↓ to move, enter to select): typec > lib#typecheck: echo typecheck lib @ write-key: enter +Selected task: lib#typecheck ~/packages/lib$ echo typecheck lib ⊘ cache disabled typecheck lib diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search preserves rating within package.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search preserves rating within package.snap index 8eaa73f7..71c5a7b7 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search preserves rating within package.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search preserves rating within package.snap @@ -37,5 +37,6 @@ Search task (↑/↓ to move, enter to select): t app#test: echo test app (…1 more) @ write-key: enter +Selected task: test ~/packages/lib$ echo test lib ⊘ cache disabled test lib diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap index 618400a0..fd4dc098 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap @@ -26,5 +26,6 @@ Search task (↑/↓ to move, enter to select): lin > lint: echo lint app lib#lint: echo lint lib @ write-key: enter +Selected task: lint ~/packages/app$ echo lint app ⊘ cache disabled lint app diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search with hash skips reorder.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search with hash skips reorder.snap index 97e14b1b..d9e0f83b 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search with hash skips reorder.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search with hash skips reorder.snap @@ -28,5 +28,6 @@ Search task (↑/↓ to move, enter to select): lib# lib#test: echo test lib lib#typecheck: echo typecheck lib @ write-key: enter +Selected task: lib#build ~/packages/lib$ echo build lib ⊘ cache disabled build lib diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task from lib.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task from lib.snap index c216f31f..8d1b475a 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task from lib.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task from lib.snap @@ -21,5 +21,6 @@ Search task (↑/↓ to move, enter to select): task-select-test#format: echo format root (…3 more) @ write-key: enter +Selected task: build ~/packages/lib$ echo build lib ⊘ cache disabled build lib diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap index ba29e6a6..3dacc4d2 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap @@ -37,5 +37,6 @@ Search task (↑/↓ to move, enter to select): task-select-test#format: echo format root (…3 more) @ write-key: enter +Selected task: lint ~/packages/app$ echo lint app ⊘ cache disabled lint app diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with recursive.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with recursive.snap deleted file mode 100644 index 5380ece4..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with recursive.snap +++ /dev/null @@ -1,31 +0,0 @@ ---- -source: crates/vite_task_bin/tests/e2e_snapshots/main.rs -expression: e2e_outputs -info: - cwd: packages/app ---- -> vp run -r -@ expect-milestone: task-select::0 -Search task (↑/↓ to move, enter to select): -> build: echo build app - lint: echo lint app - test: echo test app - lib#build: echo build lib - lib#lint: echo lint lib - lib#test: echo test lib - lib#typecheck: echo typecheck lib - task-select-test#check: echo check root - task-select-test#clean: echo clean root - task-select-test#deploy: echo deploy root - task-select-test#docs: echo docs root - task-select-test#format: echo format root - (…3 more) -@ write-key: enter -~/packages/lib$ echo build lib ⊘ cache disabled -build lib - -~/packages/app$ echo build app ⊘ cache disabled -build app - ---- -[vp run] 0/2 cache hit (0%). (Run `vp run --last-details` for full details) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo and transitive.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo and transitive.snap deleted file mode 100644 index 3d33262e..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo and transitive.snap +++ /dev/null @@ -1,21 +0,0 @@ ---- -source: crates/vite_task_bin/tests/e2e_snapshots/main.rs -expression: e2e_outputs -info: - cwd: packages/app ---- -> vp run -t buid -@ expect-milestone: task-select:buid:0 -Task "buid" not found. -Search task (↑/↓ to move, enter to select): buid -> build: echo build app - lib#build: echo build lib -@ write-key: enter -~/packages/lib$ echo build lib ⊘ cache disabled -build lib - -~/packages/app$ echo build app ⊘ cache disabled -build app - ---- -[vp run] 0/2 cache hit (0%). (Run `vp run --last-details` for full details) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo.snap index 8a43077b..34bae938 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo.snap @@ -11,5 +11,6 @@ Search task (↑/↓ to move, enter to select): buid > build: echo build app lib#build: echo build lib @ write-key: enter +Selected task: build ~/packages/app$ echo build app ⊘ cache disabled build app diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive did you mean with recursive.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive recursive typo errors.snap similarity index 56% rename from crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive did you mean with recursive.snap rename to crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive recursive typo errors.snap index f8940b84..47776655 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive did you mean with recursive.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive recursive typo errors.snap @@ -3,6 +3,4 @@ source: crates/vite_task_bin/tests/e2e_snapshots/main.rs expression: e2e_outputs --- [1]> echo '' | vp run -r buid -Task "buid" not found. Did you mean: - app#build: echo build app - lib#build: echo build lib +Error: Task "buid" not found 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 new file mode 100644 index 00000000..c67ca9a9 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/recursive without task errors.snap @@ -0,0 +1,8 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +info: + cwd: packages/app +--- +[1]> vp run -r +Error: 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 new file mode 100644 index 00000000..8c41bed6 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/transitive typo errors.snap @@ -0,0 +1,8 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +info: + cwd: packages/app +--- +[1]> vp run -t buid +Error: Task "buid" not found 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 51c5d7aa..88faadab 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 @@ -6,4 +6,4 @@ expression: e2e_outputs Error: Failed to plan tasks from `vp run nonexistent-xyz` in task task-select-test#run-typo-task Caused by: - no tasks matched the query + Task "nonexistent-xyz" not found diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/verbose with typo enters selector.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/verbose with typo enters selector.snap new file mode 100644 index 00000000..7fe98fbe --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/verbose with typo enters selector.snap @@ -0,0 +1,30 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +info: + cwd: packages/app +--- +> vp run buid --verbose +@ expect-milestone: task-select:buid:0 +Task "buid" not found. +Search task (↑/↓ to move, enter to select): buid +> build: echo build app + lib#build: echo build lib +@ write-key: enter +Selected task: build +~/packages/app$ echo build app ⊘ cache disabled +build app + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] app#build: ~/packages/app$ echo build app ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 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 new file mode 100644 index 00000000..a270a238 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/verbose without task errors.snap @@ -0,0 +1,8 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +info: + cwd: packages/app +--- +[1]> vp run --verbose +Error: No task specifier provided for 'run' command diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs index cd5d9e2a..94dc0a8b 100644 --- a/crates/vite_task_plan/src/error.rs +++ b/crates/vite_task_plan/src/error.rs @@ -144,8 +144,8 @@ pub enum Error { /// /// At the top level, an empty execution graph triggers the task selector UI. /// In a nested context there is no UI, so this is returned as an error instead. - #[error("no tasks matched the query")] - NoTasksMatched, + #[error("Task \"{0}\" not found")] + NoTasksMatched(Str), /// A cycle was detected in the task dependency graph during planning. /// diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index 3c8535de..56841017 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -177,6 +177,8 @@ async fn plan_task_as_execution_node( let execution_item_kind: ExecutionItemKind = match plan_request { // Expand task query like `vp run -r build` Some(PlanRequest::Query(query_plan_request)) => { + // Save task name before consuming the request + let task_name = query_plan_request.query.task_name.clone(); // Add prefix envs to the context context.add_envs(and_item.envs.iter()); let execution_graph = plan_query_request(query_plan_request, context) @@ -193,7 +195,7 @@ async fn plan_task_as_execution_node( return Err(Error::NestPlan { task_display: task_node.task_display.clone(), command: Str::from(&command_str[add_item_span]), - error: Box::new(Error::NoTasksMatched), + error: Box::new(Error::NoTasksMatched(task_name)), }); } ExecutionItemKind::Expanded(execution_graph) diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index a4dc414a..7cc9a41b 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -214,7 +214,7 @@ pub enum PackageQueryError { /// /// Use `#[clap(flatten)]` to embed these in a parent clap struct. /// Call [`into_package_query`](Self::into_package_query) to convert into an opaque [`PackageQuery`]. -#[derive(Debug, Clone, clap::Args)] +#[derive(Debug, Clone, PartialEq, Eq, clap::Args)] pub struct PackageQueryArgs { /// Select all packages in the workspace. #[clap(default_value = "false", short, long)] From c746a7e0d751da8c551d7155dc7b9e4625c678af Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 18:12:00 +0800 Subject: [PATCH 2/8] fix: use try_parse_from with empty args for bare RunCommand detection Co-Authored-By: Claude Opus 4.6 --- crates/vite_task/src/session/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 55d87377..5c65b51b 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -244,7 +244,9 @@ impl<'a> Session<'a> { std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); // Detect bare `vp run` (no task, no flags, no extra args) - let bare = RunCommand::parse_from(["vp"]).into_resolved(); + let bare = RunCommand::try_parse_from::<_, &str>([]) + .expect("parsing hardcoded bare command should never fail") + .into_resolved(); let is_bare = run_command == bare; // Save task name and flags before consuming run_command From cc3b54e1199f99867989b38cb655c19342907cf3 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 18:16:28 +0800 Subject: [PATCH 3/8] refactor: change task_specifier to Option in RunCommand and ResolvedRunCommand Defer TaskSpecifier parsing to into_query_plan_request where the structured fields are actually needed. Co-Authored-By: Claude Opus 4.6 --- crates/vite_task/src/cli/mod.rs | 7 ++++--- crates/vite_task/src/session/mod.rs | 7 +++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/vite_task/src/cli/mod.rs b/crates/vite_task/src/cli/mod.rs index 2056c187..a75f8324 100644 --- a/crates/vite_task/src/cli/mod.rs +++ b/crates/vite_task/src/cli/mod.rs @@ -39,7 +39,7 @@ pub struct RunFlags { #[derive(Debug, clap::Parser)] pub struct RunCommand { /// `packageName#taskName` or `taskName`. If omitted, lists all available tasks. - pub(crate) task_specifier: Option, + pub(crate) task_specifier: Option, #[clap(flatten)] pub(crate) flags: RunFlags, @@ -112,7 +112,7 @@ pub enum ResolvedCommand { #[derive(Debug, PartialEq, Eq)] pub struct ResolvedRunCommand { /// `packageName#taskName` or `taskName`. If omitted, lists all available tasks. - pub task_specifier: Option, + pub task_specifier: Option, pub flags: RunFlags, @@ -152,7 +152,8 @@ impl ResolvedRunCommand { self, cwd: &Arc, ) -> Result<(QueryPlanRequest, bool), CLITaskQueryError> { - let task_specifier = self.task_specifier.ok_or(CLITaskQueryError::MissingTaskSpecifier)?; + let raw_specifier = self.task_specifier.ok_or(CLITaskQueryError::MissingTaskSpecifier)?; + let task_specifier = TaskSpecifier::parse_raw(&raw_specifier); let (package_query, is_cwd_only) = self.flags.package_query.into_package_query(task_specifier.package_name, cwd)?; diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 5c65b51b..a7a4d43c 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -20,7 +20,7 @@ use vite_path::{AbsolutePath, AbsolutePathBuf}; use vite_select::SelectItem; use vite_str::Str; use vite_task_graph::{ - IndexedTaskGraph, TaskGraph, TaskGraphLoadError, TaskSpecifier, config::user::UserCacheConfig, + IndexedTaskGraph, TaskGraph, TaskGraphLoadError, config::user::UserCacheConfig, loader::UserConfigLoader, }; use vite_task_plan::{ @@ -250,7 +250,7 @@ impl<'a> Session<'a> { let is_bare = run_command == bare; // Save task name and flags before consuming run_command - let task_name = run_command.task_specifier.as_ref().map(|s| s.task_name.clone()); + let task_name = run_command.task_specifier.clone(); let show_details = run_command.flags.verbose; let flags = run_command.flags.clone(); let additional_args = run_command.additional_args.clone(); @@ -419,10 +419,9 @@ impl<'a> Session<'a> { selected_label, )?; } - let task_specifier = TaskSpecifier::parse_raw(selected_label); let show_details = flags.verbose; let run_command = RunCommand { - task_specifier: Some(task_specifier), + task_specifier: Some(selected_label.clone()), flags, additional_args, last_details: false, From b3c863412b888492532b2291790a35a3368fdf53 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 18:20:11 +0800 Subject: [PATCH 4/8] refactor: pass ResolvedRunCommand to handle_no_task Derive Clone on ResolvedRunCommand and pass the whole struct instead of individual flags and additional_args fields. Co-Authored-By: Claude Opus 4.6 --- crates/vite_task/src/cli/mod.rs | 2 +- crates/vite_task/src/session/mod.rs | 40 ++++++++++------------------- 2 files changed, 14 insertions(+), 28 deletions(-) diff --git a/crates/vite_task/src/cli/mod.rs b/crates/vite_task/src/cli/mod.rs index a75f8324..15adc85f 100644 --- a/crates/vite_task/src/cli/mod.rs +++ b/crates/vite_task/src/cli/mod.rs @@ -109,7 +109,7 @@ pub enum ResolvedCommand { /// /// Does not contain `last_details` — that case is represented by /// [`ResolvedCommand::RunLastDetails`] instead. -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolvedRunCommand { /// `packageName#taskName` or `taskName`. If omitted, lists all available tasks. pub task_specifier: Option, diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index a7a4d43c..3e585313 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -30,7 +30,7 @@ use vite_task_plan::{ }; use vite_workspace::{WorkspaceRoot, find_workspace_root}; -use crate::cli::{CacheSubcommand, Command, ResolvedCommand, RunCommand, RunFlags}; +use crate::cli::{CacheSubcommand, Command, ResolvedCommand, ResolvedRunCommand, RunCommand}; #[derive(Debug)] enum LazyTaskGraph<'a> { @@ -249,25 +249,16 @@ impl<'a> Session<'a> { .into_resolved(); let is_bare = run_command == bare; - // Save task name and flags before consuming run_command - let task_name = run_command.task_specifier.clone(); - let show_details = run_command.flags.verbose; - let flags = run_command.flags.clone(); - let additional_args = run_command.additional_args.clone(); + // Clone before consuming — needed for handle_no_task and error paths + let saved_run_command = run_command.clone(); match self.plan_from_cli_run_resolved(cwd, run_command).await { Ok((ref graph, is_cwd_only)) if graph.node_count() == 0 => { if is_cwd_only { - self.handle_no_task( - is_interactive, - task_name.as_deref(), - flags, - additional_args, - ) - .await + self.handle_no_task(is_interactive, saved_run_command).await } else { return Err(vite_task_plan::Error::NoTasksMatched( - task_name.unwrap_or_default(), + saved_run_command.task_specifier.unwrap_or_default(), ) .into()); } @@ -276,7 +267,7 @@ impl<'a> Session<'a> { let builder = LabeledReporterBuilder::new( self.workspace_path(), Box::new(tokio::io::stdout()), - show_details, + saved_run_command.flags.verbose, Some(self.make_summary_writer()), ); Ok(self @@ -287,7 +278,7 @@ impl<'a> Session<'a> { } Err(err) if err.is_missing_task_specifier() => { if is_bare { - self.handle_no_task(is_interactive, None, flags, additional_args).await + self.handle_no_task(is_interactive, saved_run_command).await } else { return Err(vite_task_plan::Error::MissingTaskSpecifier.into()); } @@ -320,10 +311,9 @@ impl<'a> Session<'a> { async fn handle_no_task( &mut self, is_interactive: bool, - not_found_name: Option<&str>, - flags: RunFlags, - additional_args: Vec, + run_command: ResolvedRunCommand, ) -> anyhow::Result { + let not_found_name = run_command.task_specifier.as_deref(); let cwd = Arc::clone(&self.cwd); let task_graph = self.ensure_task_graph_loaded().await?; let current_package_path = task_graph.get_package_path_from_cwd(&cwd).cloned(); @@ -419,16 +409,12 @@ impl<'a> Session<'a> { selected_label, )?; } - let show_details = flags.verbose; - let run_command = RunCommand { - task_specifier: Some(selected_label.clone()), - flags, - additional_args, - last_details: false, - }; + let mut run_command = run_command; + let show_details = run_command.flags.verbose; + run_command.task_specifier = Some(selected_label.clone()); let cwd = Arc::clone(&self.cwd); - let graph = self.plan_from_cli_run(cwd, run_command).await?; + let graph = self.plan_from_cli_run_resolved(cwd, run_command).await?.0; let builder = LabeledReporterBuilder::new( self.workspace_path(), Box::new(tokio::io::stdout()), From e136c2239849080207694a93694407eb38e8f369 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 22:43:36 +0800 Subject: [PATCH 5/8] refactor: have handle_no_task take &mut ResolvedRunCommand and return Option handle_no_task now mutates run_command.task_specifier in place on interactive selection and returns None, letting the caller plan and execute through the single LabeledReporterBuilder + execute_graph site. Non-interactive list mode returns Some(ExitStatus) directly. Co-Authored-By: Claude Opus 4.6 --- crates/vite_task/src/session/mod.rs | 96 ++++++++++++++++------------- 1 file changed, 53 insertions(+), 43 deletions(-) diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 3e585313..53e000a6 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -238,53 +238,70 @@ impl<'a> Session<'a> { match command.into_resolved() { ResolvedCommand::Cache { ref subcmd } => self.handle_cache_command(subcmd), ResolvedCommand::RunLastDetails => self.show_last_run_details(), - ResolvedCommand::Run(run_command) => { + ResolvedCommand::Run(mut run_command) => { let cwd = Arc::clone(&self.cwd); let is_interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); - // Detect bare `vp run` (no task, no flags, no extra args) + // Detect bare `vp run` (no task, no flags, no extra args). + // Only bare invocations enter the selector on MissingTaskSpecifier; + // non-bare invocations like `vp run --verbose` error instead. let bare = RunCommand::try_parse_from::<_, &str>([]) .expect("parsing hardcoded bare command should never fail") .into_resolved(); let is_bare = run_command == bare; - // Clone before consuming — needed for handle_no_task and error paths - let saved_run_command = run_command.clone(); - - match self.plan_from_cli_run_resolved(cwd, run_command).await { + let graph = match self.plan_from_cli_run_resolved(cwd, run_command.clone()).await { + // Planning succeeded but matched zero tasks. + // With is_cwd_only (no scope flags) the task name is a typo + // or missing — show the selector. With scope flags, error. Ok((ref graph, is_cwd_only)) if graph.node_count() == 0 => { if is_cwd_only { - self.handle_no_task(is_interactive, saved_run_command).await + if let Some(status) = + self.handle_no_task(is_interactive, &mut run_command).await? + { + return Ok(status); + } + let cwd = Arc::clone(&self.cwd); + self.plan_from_cli_run_resolved(cwd, run_command.clone()).await?.0 } else { return Err(vite_task_plan::Error::NoTasksMatched( - saved_run_command.task_specifier.unwrap_or_default(), + run_command.task_specifier.unwrap_or_default(), ) .into()); } } - Ok((graph, _)) => { - let builder = LabeledReporterBuilder::new( - self.workspace_path(), - Box::new(tokio::io::stdout()), - saved_run_command.flags.verbose, - Some(self.make_summary_writer()), - ); - Ok(self - .execute_graph(graph, Box::new(builder)) - .await - .err() - .unwrap_or(ExitStatus::SUCCESS)) - } + // Planning succeeded with tasks — execute them. + Ok((graph, _)) => graph, + // No task specifier at all (e.g. `vp run` or `vp run --verbose`). + // Bare invocations enter the selector; non-bare ones error. Err(err) if err.is_missing_task_specifier() => { if is_bare { - self.handle_no_task(is_interactive, saved_run_command).await + if let Some(status) = + self.handle_no_task(is_interactive, &mut run_command).await? + { + return Ok(status); + } + let cwd = Arc::clone(&self.cwd); + self.plan_from_cli_run_resolved(cwd, run_command.clone()).await?.0 } else { return Err(vite_task_plan::Error::MissingTaskSpecifier.into()); } } - Err(err) => Err(err.into()), - } + Err(err) => return Err(err.into()), + }; + + let builder = LabeledReporterBuilder::new( + self.workspace_path(), + Box::new(tokio::io::stdout()), + run_command.flags.verbose, + Some(self.make_summary_writer()), + ); + Ok(self + .execute_graph(graph, Box::new(builder)) + .await + .err() + .unwrap_or(ExitStatus::SUCCESS)) } } } @@ -300,10 +317,14 @@ impl<'a> Session<'a> { } } - /// Handle the case where no task was specified or a task name was not found. + /// Show the task selector or list, and update the run command with the selected task. + /// + /// In interactive mode, shows a fuzzy-searchable selection list. On selection, + /// updates `run_command.task_specifier` and returns `Ok(None)` so the caller + /// can plan and execute the selected task. /// - /// In interactive mode, shows a fuzzy-searchable selection list. - /// In non-interactive mode, prints the task list or "did you mean" suggestions. + /// In non-interactive mode, prints the task list (or "did you mean" suggestions) + /// and returns `Ok(Some(ExitStatus))` — no further execution needed. #[expect( clippy::future_not_send, reason = "session is single-threaded, futures do not need to be Send" @@ -311,8 +332,8 @@ impl<'a> Session<'a> { async fn handle_no_task( &mut self, is_interactive: bool, - run_command: ResolvedRunCommand, - ) -> anyhow::Result { + run_command: &mut ResolvedRunCommand, + ) -> anyhow::Result> { let not_found_name = run_command.task_specifier.as_deref(); let cwd = Arc::clone(&self.cwd); let task_graph = self.ensure_task_graph_loaded().await?; @@ -390,9 +411,9 @@ impl<'a> Session<'a> { let Some(selected_index) = selected_index else { // Non-interactive: if no task was found, return failure. Otherwise, print the list and return return if not_found_name.is_some() { - Ok(ExitStatus::FAILURE) + Ok(Some(ExitStatus::FAILURE)) } else { - Ok(ExitStatus::SUCCESS) + Ok(Some(ExitStatus::SUCCESS)) }; }; @@ -409,19 +430,8 @@ impl<'a> Session<'a> { selected_label, )?; } - let mut run_command = run_command; - let show_details = run_command.flags.verbose; run_command.task_specifier = Some(selected_label.clone()); - - let cwd = Arc::clone(&self.cwd); - let graph = self.plan_from_cli_run_resolved(cwd, run_command).await?.0; - let builder = LabeledReporterBuilder::new( - self.workspace_path(), - Box::new(tokio::io::stdout()), - show_details, - Some(self.make_summary_writer()), - ); - Ok(self.execute_graph(graph, Box::new(builder)).await.err().unwrap_or(ExitStatus::SUCCESS)) + Ok(None) } /// Lazily initializes and returns the execution cache. From 67935e389ccccee5c79dfe8d28de1fe706f432d2 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 22:53:47 +0800 Subject: [PATCH 6/8] refactor: use if-let on task_specifier instead of error matching Check task_specifier before calling plan_from_cli_run_resolved, removing the is_missing_task_specifier error path and unwrap_or_default. Co-Authored-By: Claude Opus 4.6 --- crates/vite_task/src/session/mod.rs | 59 +++++++++++++---------------- crates/vite_task_plan/src/error.rs | 7 ---- 2 files changed, 27 insertions(+), 39 deletions(-) diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 53e000a6..0a9c1fd9 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -239,23 +239,18 @@ impl<'a> Session<'a> { ResolvedCommand::Cache { ref subcmd } => self.handle_cache_command(subcmd), ResolvedCommand::RunLastDetails => self.show_last_run_details(), ResolvedCommand::Run(mut run_command) => { - let cwd = Arc::clone(&self.cwd); let is_interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); - // Detect bare `vp run` (no task, no flags, no extra args). - // Only bare invocations enter the selector on MissingTaskSpecifier; - // non-bare invocations like `vp run --verbose` error instead. - let bare = RunCommand::try_parse_from::<_, &str>([]) - .expect("parsing hardcoded bare command should never fail") - .into_resolved(); - let is_bare = run_command == bare; - - let graph = match self.plan_from_cli_run_resolved(cwd, run_command.clone()).await { - // Planning succeeded but matched zero tasks. - // With is_cwd_only (no scope flags) the task name is a typo - // or missing — show the selector. With scope flags, error. - Ok((ref graph, is_cwd_only)) if graph.node_count() == 0 => { + let graph = if let Some(ref task_specifier) = run_command.task_specifier { + // Task specifier provided — plan it. + let cwd = Arc::clone(&self.cwd); + let (graph, is_cwd_only) = + self.plan_from_cli_run_resolved(cwd, run_command.clone()).await?; + + if graph.node_count() == 0 { + // No tasks matched. With is_cwd_only (no scope flags) the + // task name is a typo — show the selector. Otherwise error. if is_cwd_only { if let Some(status) = self.handle_no_task(is_interactive, &mut run_command).await? @@ -266,29 +261,29 @@ impl<'a> Session<'a> { self.plan_from_cli_run_resolved(cwd, run_command.clone()).await?.0 } else { return Err(vite_task_plan::Error::NoTasksMatched( - run_command.task_specifier.unwrap_or_default(), + task_specifier.clone(), ) .into()); } + } else { + graph } - // Planning succeeded with tasks — execute them. - Ok((graph, _)) => graph, - // No task specifier at all (e.g. `vp run` or `vp run --verbose`). - // Bare invocations enter the selector; non-bare ones error. - Err(err) if err.is_missing_task_specifier() => { - if is_bare { - if let Some(status) = - self.handle_no_task(is_interactive, &mut run_command).await? - { - return Ok(status); - } - let cwd = Arc::clone(&self.cwd); - self.plan_from_cli_run_resolved(cwd, run_command.clone()).await?.0 - } else { - return Err(vite_task_plan::Error::MissingTaskSpecifier.into()); - } + } else { + // No task specifier (e.g. `vp run` or `vp run --verbose`). + // Only bare `vp run` enters the selector; with extra flags, error. + let bare = RunCommand::try_parse_from::<_, &str>([]) + .expect("parsing hardcoded bare command should never fail") + .into_resolved(); + if run_command != bare { + return Err(vite_task_plan::Error::MissingTaskSpecifier.into()); + } + if let Some(status) = + self.handle_no_task(is_interactive, &mut run_command).await? + { + return Ok(status); } - Err(err) => return Err(err.into()), + let cwd = Arc::clone(&self.cwd); + self.plan_from_cli_run_resolved(cwd, run_command.clone()).await?.0 }; let builder = LabeledReporterBuilder::new( diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs index 94dc0a8b..c2dd9090 100644 --- a/crates/vite_task_plan/src/error.rs +++ b/crates/vite_task_plan/src/error.rs @@ -155,10 +155,3 @@ pub enum Error { #[error("Cycle dependency detected: {}", _0.iter().map(std::string::ToString::to_string).collect::>().join(" -> "))] CycleDependencyDetected(Vec), } - -impl Error { - #[must_use] - pub const fn is_missing_task_specifier(&self) -> bool { - matches!(self, Self::MissingTaskSpecifier) - } -} From 4a30f0d1824803c8659c8ee3bb22c05b44e12c90 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 23:19:24 +0800 Subject: [PATCH 7/8] code cleanup --- crates/vite_task/src/lib.rs | 4 +- crates/vite_task/src/session/mod.rs | 71 +++++++++++++++++------------ crates/vite_task_bin/src/main.rs | 15 +++--- 3 files changed, 53 insertions(+), 37 deletions(-) diff --git a/crates/vite_task/src/lib.rs b/crates/vite_task/src/lib.rs index 41e7c04b..dca28e81 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, SessionCallbacks}; +pub use session::{ + CommandHandler, ExitStatus, HandledCommand, Session, SessionCallbacks, SessionError, +}; 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 0a9c1fd9..83b6aa5d 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -32,6 +32,25 @@ use vite_workspace::{WorkspaceRoot, find_workspace_root}; use crate::cli::{CacheSubcommand, Command, ResolvedCommand, ResolvedRunCommand, RunCommand}; +/// Error type for [`Session::main`]. +/// +/// `EarlyExit` represents a non-error exit (e.g. printing a task list) and +/// the caller should exit with the contained status without printing an error. +/// It exists only for easier `?` control flow. +pub enum SessionError { + Anyhow(anyhow::Error), + EarlyExit(ExitStatus), +} + +impl From for SessionError +where + anyhow::Error: From, +{ + fn from(err: T) -> Self { + Self::Anyhow(anyhow::Error::from(err)) + } +} + #[derive(Debug)] enum LazyTaskGraph<'a> { Uninitialized { workspace_root: WorkspaceRoot, config_loader: &'a dyn UserConfigLoader }, @@ -234,7 +253,7 @@ impl<'a> Session<'a> { clippy::future_not_send, reason = "session is single-threaded, futures do not need to be Send" )] - pub async fn main(mut self, command: Command) -> anyhow::Result { + pub async fn main(mut self, command: Command) -> Result<(), SessionError> { match command.into_resolved() { ResolvedCommand::Cache { ref subcmd } => self.handle_cache_command(subcmd), ResolvedCommand::RunLastDetails => self.show_last_run_details(), @@ -252,11 +271,7 @@ impl<'a> Session<'a> { // No tasks matched. With is_cwd_only (no scope flags) the // task name is a typo — show the selector. Otherwise error. if is_cwd_only { - if let Some(status) = - self.handle_no_task(is_interactive, &mut run_command).await? - { - return Ok(status); - } + self.handle_no_task(is_interactive, &mut run_command).await?; let cwd = Arc::clone(&self.cwd); self.plan_from_cli_run_resolved(cwd, run_command.clone()).await?.0 } else { @@ -277,11 +292,7 @@ impl<'a> Session<'a> { if run_command != bare { return Err(vite_task_plan::Error::MissingTaskSpecifier.into()); } - if let Some(status) = - self.handle_no_task(is_interactive, &mut run_command).await? - { - return Ok(status); - } + self.handle_no_task(is_interactive, &mut run_command).await?; let cwd = Arc::clone(&self.cwd); self.plan_from_cli_run_resolved(cwd, run_command.clone()).await?.0 }; @@ -292,34 +303,32 @@ impl<'a> Session<'a> { run_command.flags.verbose, Some(self.make_summary_writer()), ); - Ok(self - .execute_graph(graph, Box::new(builder)) + self.execute_graph(graph, Box::new(builder)) .await - .err() - .unwrap_or(ExitStatus::SUCCESS)) + .map_err(|status| SessionError::EarlyExit(status)) } } } - fn handle_cache_command(&self, subcmd: &CacheSubcommand) -> anyhow::Result { + fn handle_cache_command(&self, subcmd: &CacheSubcommand) -> Result<(), SessionError> { match subcmd { CacheSubcommand::Clean => { if self.cache_path.as_path().exists() { std::fs::remove_dir_all(&self.cache_path)?; } - Ok(ExitStatus::SUCCESS) } } + Ok(()) } /// Show the task selector or list, and update the run command with the selected task. /// /// In interactive mode, shows a fuzzy-searchable selection list. On selection, - /// updates `run_command.task_specifier` and returns `Ok(None)` so the caller + /// updates `run_command.task_specifier` and returns `Ok(())` so the caller /// can plan and execute the selected task. /// /// In non-interactive mode, prints the task list (or "did you mean" suggestions) - /// and returns `Ok(Some(ExitStatus))` — no further execution needed. + /// and returns `Err(SessionError::EarlyExit(_))` — no further execution needed. #[expect( clippy::future_not_send, reason = "session is single-threaded, futures do not need to be Send" @@ -328,7 +337,7 @@ impl<'a> Session<'a> { &mut self, is_interactive: bool, run_command: &mut ResolvedRunCommand, - ) -> anyhow::Result> { + ) -> Result<(), SessionError> { let not_found_name = run_command.task_specifier.as_deref(); let cwd = Arc::clone(&self.cwd); let task_graph = self.ensure_task_graph_loaded().await?; @@ -404,12 +413,14 @@ impl<'a> Session<'a> { )?; let Some(selected_index) = selected_index else { - // Non-interactive: if no task was found, return failure. Otherwise, print the list and return - return if not_found_name.is_some() { - Ok(Some(ExitStatus::FAILURE)) + // Non-interactive, the list was printed. + return Err(SessionError::EarlyExit(if not_found_name.is_some() { + // For `vp run typo`, return FAILURE status + ExitStatus::FAILURE } else { - Ok(Some(ExitStatus::SUCCESS)) - }; + // For bare `vp run`, return SUCCESS status + ExitStatus::SUCCESS + })); }; // Interactive: print selected task and run it @@ -426,7 +437,7 @@ impl<'a> Session<'a> { )?; } run_command.task_specifier = Some(selected_label.clone()); - Ok(None) + Ok(()) } /// Lazily initializes and returns the execution cache. @@ -467,7 +478,7 @@ impl<'a> Session<'a> { clippy::print_stderr, reason = "--last-details error messages are user-facing diagnostics, not debug output" )] - fn show_last_run_details(&self) -> anyhow::Result { + fn show_last_run_details(&self) -> Result<(), SessionError> { let path = self.summary_file_path(); match LastRunSummary::read_from_path(&path) { Ok(Some(summary)) => { @@ -478,18 +489,18 @@ impl<'a> Session<'a> { stdout.write_all(&buf)?; stdout.flush()?; } - Ok(ExitStatus(summary.exit_code)) + Err(SessionError::EarlyExit(ExitStatus(summary.exit_code))) } Ok(None) => { eprintln!("No previous run summary found. Run a task first to generate a summary."); - Ok(ExitStatus::FAILURE) + Err(SessionError::EarlyExit(ExitStatus::FAILURE)) } Err(ReadSummaryError::IncompatibleVersion) => { eprintln!( "Summary data was saved by a different version of vite-task and cannot be read. \ Run a task to generate a new summary." ); - Ok(ExitStatus::FAILURE) + Err(SessionError::EarlyExit(ExitStatus::FAILURE)) } Err(ReadSummaryError::Io(err)) => Err(err.into()), } diff --git a/crates/vite_task_bin/src/main.rs b/crates/vite_task_bin/src/main.rs index b4a1320b..1653c24f 100644 --- a/crates/vite_task_bin/src/main.rs +++ b/crates/vite_task_bin/src/main.rs @@ -3,7 +3,7 @@ use std::{process::ExitCode, sync::Arc}; use clap::Parser; use vite_str::Str; use vite_task::{ - EnabledCacheConfig, ExitStatus, Session, UserCacheConfig, get_path_env, + EnabledCacheConfig, ExitStatus, Session, SessionError, UserCacheConfig, get_path_env, plan_request::SyntheticPlanRequest, }; use vite_task_bin::{Args, OwnedSessionCallbacks, find_executable}; @@ -11,12 +11,15 @@ use vite_task_bin::{Args, OwnedSessionCallbacks, find_executable}; #[tokio::main] async fn main() -> anyhow::Result { #[expect(clippy::large_futures, reason = "top-level await in main, no alternative")] - let exit_status = run().await?; - Ok(exit_status.0.into()) + match run().await { + Ok(()) => Ok(ExitCode::SUCCESS), + Err(SessionError::EarlyExit(status)) => Ok(ExitCode::from(status.0 as u8)), + Err(SessionError::Anyhow(anyhow_err)) => Err(anyhow_err), + } } #[expect(clippy::future_not_send, reason = "Session contains !Send types; single-threaded runtime")] -async fn run() -> anyhow::Result { +async fn run() -> Result<(), SessionError> { let args = Args::parse(); let mut owned_callbacks = OwnedSessionCallbacks::default(); let session = Session::init(owned_callbacks.as_callbacks())?; @@ -46,14 +49,14 @@ async fn run() -> anyhow::Result { )] let status = session.execute_synthetic(request, cache_key, true).await?; if status != ExitStatus::SUCCESS { - return Ok(status); + return Err(SessionError::EarlyExit(status)); } } #[expect(clippy::print_stdout, reason = "CLI binary output for non-task commands")] { println!("{args:?}"); } - Ok(ExitStatus::SUCCESS) + Ok(()) } } } From 35aa6348ffec830172a140c5afdff51151529936 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 23:25:03 +0800 Subject: [PATCH 8/8] refactor: make SessionError private with main_inner pattern Co-Authored-By: Claude Opus 4.6 --- crates/vite_task/src/lib.rs | 4 +--- crates/vite_task/src/session/mod.rs | 23 ++++++++++++++++++----- crates/vite_task_bin/src/main.rs | 15 ++++++--------- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/crates/vite_task/src/lib.rs b/crates/vite_task/src/lib.rs index dca28e81..41e7c04b 100644 --- a/crates/vite_task/src/lib.rs +++ b/crates/vite_task/src/lib.rs @@ -5,9 +5,7 @@ pub mod session; // Public exports for vite_task_bin pub use cli::{CacheSubcommand, Command, RunCommand, RunFlags}; -pub use session::{ - CommandHandler, ExitStatus, HandledCommand, Session, SessionCallbacks, SessionError, -}; +pub use session::{CommandHandler, ExitStatus, HandledCommand, Session, SessionCallbacks}; 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 83b6aa5d..833c2e8f 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -37,7 +37,7 @@ use crate::cli::{CacheSubcommand, Command, ResolvedCommand, ResolvedRunCommand, /// `EarlyExit` represents a non-error exit (e.g. printing a task list) and /// the caller should exit with the contained status without printing an error. /// It exists only for easier `?` control flow. -pub enum SessionError { +enum SessionError { Anyhow(anyhow::Error), EarlyExit(ExitStatus), } @@ -253,7 +253,22 @@ impl<'a> Session<'a> { clippy::future_not_send, reason = "session is single-threaded, futures do not need to be Send" )] - pub async fn main(mut self, command: Command) -> Result<(), SessionError> { + pub async fn main(mut self, command: Command) -> anyhow::Result { + match self.main_inner(command).await { + Ok(()) => Ok(ExitStatus::SUCCESS), + Err(SessionError::EarlyExit(status)) => Ok(status), + Err(SessionError::Anyhow(err)) => Err(err), + } + } + + /// # Panics + /// + /// Panics if parsing a hardcoded bare `RunCommand` fails (should never happen). + #[expect( + clippy::future_not_send, + reason = "session is single-threaded, futures do not need to be Send" + )] + async fn main_inner(&mut self, command: Command) -> Result<(), SessionError> { match command.into_resolved() { ResolvedCommand::Cache { ref subcmd } => self.handle_cache_command(subcmd), ResolvedCommand::RunLastDetails => self.show_last_run_details(), @@ -303,9 +318,7 @@ impl<'a> Session<'a> { run_command.flags.verbose, Some(self.make_summary_writer()), ); - self.execute_graph(graph, Box::new(builder)) - .await - .map_err(|status| SessionError::EarlyExit(status)) + self.execute_graph(graph, Box::new(builder)).await.map_err(SessionError::EarlyExit) } } } diff --git a/crates/vite_task_bin/src/main.rs b/crates/vite_task_bin/src/main.rs index 1653c24f..b4a1320b 100644 --- a/crates/vite_task_bin/src/main.rs +++ b/crates/vite_task_bin/src/main.rs @@ -3,7 +3,7 @@ use std::{process::ExitCode, sync::Arc}; use clap::Parser; use vite_str::Str; use vite_task::{ - EnabledCacheConfig, ExitStatus, Session, SessionError, UserCacheConfig, get_path_env, + EnabledCacheConfig, ExitStatus, Session, UserCacheConfig, get_path_env, plan_request::SyntheticPlanRequest, }; use vite_task_bin::{Args, OwnedSessionCallbacks, find_executable}; @@ -11,15 +11,12 @@ use vite_task_bin::{Args, OwnedSessionCallbacks, find_executable}; #[tokio::main] async fn main() -> anyhow::Result { #[expect(clippy::large_futures, reason = "top-level await in main, no alternative")] - match run().await { - Ok(()) => Ok(ExitCode::SUCCESS), - Err(SessionError::EarlyExit(status)) => Ok(ExitCode::from(status.0 as u8)), - Err(SessionError::Anyhow(anyhow_err)) => Err(anyhow_err), - } + let exit_status = run().await?; + Ok(exit_status.0.into()) } #[expect(clippy::future_not_send, reason = "Session contains !Send types; single-threaded runtime")] -async fn run() -> Result<(), SessionError> { +async fn run() -> anyhow::Result { let args = Args::parse(); let mut owned_callbacks = OwnedSessionCallbacks::default(); let session = Session::init(owned_callbacks.as_callbacks())?; @@ -49,14 +46,14 @@ async fn run() -> Result<(), SessionError> { )] let status = session.execute_synthetic(request, cache_key, true).await?; if status != ExitStatus::SUCCESS { - return Err(SessionError::EarlyExit(status)); + return Ok(status); } } #[expect(clippy::print_stdout, reason = "CLI binary output for non-task commands")] { println!("{args:?}"); } - Ok(()) + Ok(ExitStatus::SUCCESS) } } }