Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Changelog

- **Fixed** A mistyped or misplaced setting in a task now gets an error that names it. For example, `cache: { outputs: [...] }` now fails with an `unknown field` error that names `outputs` and lists the accepted settings, instead of `data did not match any variant of untagged enum UserTaskDefinition` ([vite-plus#2635](https://github.com/voidzero-dev/vite-plus/issues/2635), [#815](https://github.com/voidzero-dev/vite-task/pull/815)).
- **Changed** When a task isn't cached because it wrote a file it also read, `vp run --last-details` now says the task read and wrote the file, and shows the `cache: { input, output }` exclusions that let the task be cached ([#784](https://github.com/voidzero-dev/vite-task/pull/784)).
- **Changed** The run summary now says a task that wrote a file it also read was `not cached because it modified its inputs`, and the statistics in `vp run --verbose` and `vp run --last-details` use the singular for a count of one, e.g. `1 task • 1 cache miss` ([#783](https://github.com/voidzero-dev/vite-task/pull/783)).
- **Fixed** An invalid glob in `--filter` no longer shows its error message twice ([#763](https://github.com/voidzero-dev/vite-task/pull/763)).
Expand Down
105 changes: 98 additions & 7 deletions crates/vt_graph/src/config/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
use std::sync::Arc;

use rustc_hash::FxHashMap;
use serde::Deserialize;
use serde::{
Deserialize, Deserializer,
de::{
MapAccess, SeqAccess, Visitor,
value::{MapAccessDeserializer, SeqAccessDeserializer},
},
};
#[cfg(all(test, not(clippy)))]
use ts_rs::TS;
use vec1::Vec1;
Expand Down Expand Up @@ -154,17 +160,43 @@ pub enum UserOutputEntry {
}

/// The value of a task's `cache` field.
#[derive(Debug, Deserialize, PartialEq, Eq)]
#[derive(Debug, PartialEq, Eq)]
// TS derive macro generates code using std types that clippy disallows; skip derive during linting
#[cfg_attr(all(test, not(clippy)), derive(TS), ts(rename = "TaskCache"))]
#[serde(untagged)]
#[cfg_attr(all(test, not(clippy)), derive(TS), ts(rename = "TaskCache", untagged))]
pub enum UserCacheConfig {
/// `true` enables caching with default settings; `false` disables caching.
Bool(bool),
/// Enables caching with the given settings.
Config(EnabledCacheConfig),
}

// Not `#[serde(untagged)]`: that replaces the error of every variant with a generic one, so a
// mistyped setting would not be named.
impl<'de> Deserialize<'de> for UserCacheConfig {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct CacheConfigVisitor;

impl<'de> Visitor<'de> for CacheConfigVisitor {
type Value = UserCacheConfig;

fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a boolean or an object of cache settings")
}

fn visit_bool<E>(self, enabled: bool) -> Result<Self::Value, E> {
Ok(UserCacheConfig::Bool(enabled))
}

fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
Deserialize::deserialize(MapAccessDeserializer::new(map))
.map(UserCacheConfig::Config)
}
}

deserializer.deserialize_any(CacheConfigVisitor)
}
}

impl Default for UserCacheConfig {
/// Caching enabled with default settings, used when `cache` is omitted.
fn default() -> Self {
Expand Down Expand Up @@ -319,17 +351,48 @@ pub struct UserTaskConfig {
}

/// User-defined task configuration or command-only shorthand in `vite.config.*`.
#[derive(Debug, Deserialize, PartialEq)]
#[derive(Debug, PartialEq)]
// TS derive macro generates code using std types that clippy disallows; skip derive during linting
#[cfg_attr(all(test, not(clippy)), derive(TS), ts(rename = "TaskDefinition"))]
#[serde(untagged)]
#[cfg_attr(all(test, not(clippy)), derive(TS), ts(rename = "TaskDefinition", untagged))]
pub enum UserTaskDefinition {
/// Full task object form.
Object(UserTaskConfig),
/// Command-only shorthand form using default task options.
CommandShorthand(Command),
}

// Not `#[serde(untagged)]`: that replaces the error of every variant with a generic one, so a
// mistyped field would not be named.
impl<'de> Deserialize<'de> for UserTaskDefinition {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct TaskDefinitionVisitor;

impl<'de> Visitor<'de> for TaskDefinitionVisitor {
type Value = UserTaskDefinition;

fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a command string, an array of command strings, or a task object")
}

fn visit_str<E>(self, command: &str) -> Result<Self::Value, E> {
Ok(UserTaskDefinition::CommandShorthand(Command::Single(command.into())))
}

fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<Self::Value, A::Error> {
Deserialize::deserialize(SeqAccessDeserializer::new(seq))
.map(|commands| UserTaskDefinition::CommandShorthand(Command::Array(commands)))
}

fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
Deserialize::deserialize(MapAccessDeserializer::new(map))
.map(UserTaskDefinition::Object)
}
}

deserializer.deserialize_any(TaskDefinitionVisitor)
}
}

/// Root-level cache configuration.
///
/// Controls caching behavior for the entire workspace.
Expand Down Expand Up @@ -1066,4 +1129,32 @@ mod tests {
.is_err()
);
}

#[test]
fn test_invalid_task_error_names_the_problem() {
let cases = [
(json!({ "command": "echo", "outputs": [] }), "unknown field `outputs`"),
(
json!({ "command": "echo", "cache": { "outputs": [] } }),
"unknown field `outputs`, expected one of `env`, `untrackedEnv`, `input`, \
`output`, `remote`",
),
(
json!({ "command": "echo", "cache": "yes" }),
"invalid type: string \"yes\", expected a boolean or an object of cache settings",
),
(json!({ "cache": false }), "missing field `command`"),
(json!(["echo one", 2]), "invalid type: integer `2`, expected a string"),
(
json!(123),
"invalid type: integer `123`, expected a command string, an array of command \
strings, or a task object",
),
];
for (task, expected) in cases {
let user_config_json = json!({ "tasks": { "build": task } });
let error = serde_json::from_value::<UserRunConfig>(user_config_json).unwrap_err();
assert_eq!(error.to_string(), expected);
}
}
}