diff --git a/Cargo.lock b/Cargo.lock index 58bddf31..03e7c85d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -230,6 +230,9 @@ name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -579,6 +582,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1437,17 +1446,20 @@ checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" [[package]] name = "insta" -version = "1.44.3" +version = "1.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698" +checksum = "983e3b24350c84ab8a65151f537d67afbbf7153bb9f1110e03e9fa9b07f67a5c" dependencies = [ "console", "globset", "once_cell", "pest", "pest_derive", + "regex", + "ron", "serde", "similar", + "tempfile", "walkdir", ] @@ -1995,9 +2007,9 @@ checksum = "132dca9b868d927b35b5dd728167b2dee150eb1ad686008fc71ccb298b776fca" [[package]] name = "pest" -version = "2.8.3" +version = "2.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" +checksum = "cbcfd20a6d4eeba40179f05735784ad32bdaef05ce8e8af05f180d45bb3e7e22" dependencies = [ "memchr", "ucd-trie", @@ -2005,9 +2017,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.3" +version = "2.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" +checksum = "51f72981ade67b1ca6adc26ec221be9f463f2b5839c7508998daa17c23d94d7f" dependencies = [ "pest", "pest_generator", @@ -2015,9 +2027,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.3" +version = "2.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" +checksum = "dee9efd8cdb50d719a80088b76f81aec7c41ed6d522ee750178f83883d271625" dependencies = [ "pest", "pest_meta", @@ -2028,9 +2040,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.3" +version = "2.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" +checksum = "bf1d70880e76bdc13ba52eafa6239ce793d85c8e43896507e43dd8984ff05b82" dependencies = [ "pest", "sha2", @@ -2364,6 +2376,20 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +[[package]] +name = "ron" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd490c5b18261893f14449cbd28cb9c0b637aebf161cd77900bfdedaff21ec32" +dependencies = [ + "bitflags 2.10.0", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + [[package]] name = "rusqlite" version = "0.37.0" @@ -3059,6 +3085,12 @@ dependencies = [ "rand 0.9.2", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.19.0" @@ -3186,6 +3218,15 @@ dependencies = [ "wax", ] +[[package]] +name = "vite_graph_ser" +version = "0.1.0" +dependencies = [ + "petgraph", + "serde", + "serde_json", +] + [[package]] name = "vite_path" version = "0.1.0" @@ -3231,14 +3272,12 @@ dependencies = [ "bstr", "clap", "compact_str 0.9.0", - "copy_dir", "dashmap", "derive_more", "diff-struct", "fspy", "futures-core", "futures-util", - "insta", "itertools 0.14.0", "nix 0.30.1", "owo-colors", @@ -3253,7 +3292,7 @@ dependencies = [ "tempfile", "thiserror 2.0.17", "tokio", - "toml", + "tokio-stream", "tracing", "twox-hash", "uuid", @@ -3267,6 +3306,32 @@ dependencies = [ "wax", ] +[[package]] +name = "vite_task_bin" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "clap", + "copy_dir", + "cow-utils", + "insta", + "petgraph", + "regex", + "serde", + "serde_json", + "tempfile", + "tokio", + "toml", + "vite_graph_ser", + "vite_path", + "vite_str", + "vite_task", + "vite_task_graph", + "vite_workspace", + "which", +] + [[package]] name = "vite_task_graph" version = "0.1.0" @@ -3281,6 +3346,7 @@ dependencies = [ "thiserror 2.0.17", "tokio", "vec1", + "vite_graph_ser", "vite_path", "vite_str", "vite_workspace", @@ -3292,17 +3358,22 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "bincode", "futures-util", "petgraph", + "serde", "sha2", + "shell-escape", "supports-color", "thiserror 2.0.17", "tracing", "vite_glob", + "vite_graph_ser", "vite_path", "vite_shell", "vite_str", "vite_task_graph", + "which", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 814b4921..8c72e9dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ compact_str = "0.9.0" const_format = "0.2.34" constcat = "0.6.1" copy_dir = "0.1.3" +cow-utils = "0.1.3" crossterm = { version = "0.29.0", features = ["event-stream"] } csv-async = { version = "1.3.1", features = ["tokio"] } ctor = "0.6" @@ -91,6 +92,7 @@ rand = "0.9.1" ratatui = "0.29.0" rayon = "1.10.0" ref-cast = "1.0.24" +regex = "1.11.3" rusqlite = "0.37.0" rustc-hash = "2.1.1" seccompiler = { git = "https://github.com/branchseer/seccompiler", branch = "seccomp-action-raw" } @@ -109,6 +111,7 @@ tempfile = "3.14.0" test-log = { version = "0.2.18", features = ["trace"] } thiserror = "2" tokio = "1.48.0" +tokio-stream = "0.1.17" tokio-util = "0.7.17" toml = "0.9.5" tracing = "0.1.43" @@ -119,9 +122,11 @@ twox-hash = "2.1.1" uuid = "1.18.1" vec1 = "1.12.1" vite_glob = { path = "crates/vite_glob" } +vite_graph_ser = { path = "crates/vite_graph_ser" } vite_path = { path = "crates/vite_path" } vite_shell = { path = "crates/vite_shell" } vite_str = { path = "crates/vite_str" } +vite_task = { path = "crates/vite_task" } vite_task_graph = { path = "crates/vite_task_graph" } vite_task_plan = { path = "crates/vite_task_plan" } vite_workspace = { path = "crates/vite_workspace" } diff --git a/crates/vite_graph_ser/Cargo.toml b/crates/vite_graph_ser/Cargo.toml new file mode 100644 index 00000000..d149a13a --- /dev/null +++ b/crates/vite_graph_ser/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "vite_graph_ser" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +petgraph = { workspace = true } +serde = { workspace = true, features = ["derive"] } + +[dev-dependencies] +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/vite_graph_ser/src/lib.rs b/crates/vite_graph_ser/src/lib.rs new file mode 100644 index 00000000..edc4fd5e --- /dev/null +++ b/crates/vite_graph_ser/src/lib.rs @@ -0,0 +1,132 @@ +use petgraph::{ + graph::DiGraph, + visit::{EdgeRef as _, IntoNodeReferences}, +}; +use serde::{Serialize, Serializer}; + +/// Trait for getting a unique key for a node in the graph. +/// This key is used for serializing the graph with `serialize_by_key`. +pub trait GetKey { + type Key<'a>: Serialize + Ord + where + Self: 'a; + fn key(&self) -> Result, String>; +} + +#[derive(Serialize)] +#[serde(bound = "E: Serialize, N: Serialize")] +struct DiGraphNodeItem<'a, N: GetKey, E> { + key: N::Key<'a>, + node: &'a N, + neighbors: Vec<(N::Key<'a>, &'a E)>, +} + +/// A wrapper around `DiGraph` that serializes nodes by their keys. +#[derive(Serialize)] +#[serde(transparent)] +pub struct SerializeByKey<'a, N: GetKey + Serialize, E: Serialize, Ix: petgraph::graph::IndexType>( + #[serde(serialize_with = "serialize_by_key")] pub &'a DiGraph, +); + +/// Serialize a directed graph into a map from node keys to their values and neighbors by keys. +/// +/// Keys in nodes and edges are sorted lexicographically. +/// +/// If there are multiple nodes with the same key, or multiple edges between nodes with the same keys, +/// an error will be returned. +/// +/// This is useful for serializing graphs in a stable and human-readable way. +pub fn serialize_by_key< + N: GetKey + Serialize, + E: Serialize, + Ix: petgraph::graph::IndexType, + S: Serializer, +>( + graph: &DiGraph, + serializer: S, +) -> Result { + let mut items = Vec::>::with_capacity(graph.node_count()); + for (node_idx, node) in graph.node_references() { + let mut neighbors = Vec::<(N::Key<'_>, &E)>::new(); + + for edge in graph.edges(node_idx) { + let target_idx = edge.target(); + let target_node = graph.node_weight(target_idx).unwrap(); + neighbors.push((target_node.key().map_err(serde::ser::Error::custom)?, edge.weight())); + } + neighbors.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + items.push(DiGraphNodeItem { + key: node.key().map_err(serde::ser::Error::custom)?, + node, + neighbors, + }); + } + items.sort_unstable_by(|a, b| a.key.cmp(&b.key)); + items.serialize(serializer) +} + +#[cfg(test)] +mod tests { + use petgraph::graph::DiGraph; + + use super::*; + + #[derive(Debug, Clone, Serialize)] + struct TestNode { + id: &'static str, + value: i32, + } + + impl GetKey for TestNode { + type Key<'a> + = &'a str + where + Self: 'a; + + fn key(&self) -> Result, String> { + Ok(self.id) + } + } + + #[derive(Serialize)] + struct GraphWrapper { + #[serde(serialize_with = "serialize_by_key")] + graph: DiGraph, + } + + #[test] + fn test_serialize_graph_happy_path() { + let mut graph = DiGraph::::new(); + let a = graph.add_node(TestNode { id: "a", value: 1 }); + let b = graph.add_node(TestNode { id: "b", value: 2 }); + let c = graph.add_node(TestNode { id: "c", value: 3 }); + + graph.add_edge(a, b, "a->b"); + graph.add_edge(a, c, "a->c"); + graph.add_edge(b, c, "b->c"); + + let json = serde_json::to_value(GraphWrapper { graph }).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "graph": [ + { + "key": "a", + "node": {"id": "a", "value": 1}, + "neighbors": [["b", "a->b"], ["c", "a->c"]] + }, + { + "key": "b", + "node": {"id": "b", "value": 2}, + "neighbors": [["c", "b->c"]] + }, + { + "key": "c", + "node": {"id": "c", "value": 3}, + "neighbors": [] + } + ] + }) + ); + } +} diff --git a/crates/vite_path/src/absolute/mod.rs b/crates/vite_path/src/absolute/mod.rs index 928327cc..909a2d52 100644 --- a/crates/vite_path/src/absolute/mod.rs +++ b/crates/vite_path/src/absolute/mod.rs @@ -3,7 +3,7 @@ pub mod redaction; use std::{ ffi::OsStr, - fmt::Display, + fmt::{Debug, Display}, hash::Hash, ops::Deref, path::{Path, PathBuf}, @@ -16,7 +16,7 @@ use serde::Serialize; use crate::relative::{FromPathError, InvalidPathDataError, RelativePathBuf}; /// A path that is guaranteed to be absolute -#[derive(RefCastCustom, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(RefCastCustom, PartialEq, Eq, PartialOrd, Ord)] #[repr(transparent)] pub struct AbsolutePath(Path); impl AsRef for AbsolutePath { @@ -25,30 +25,27 @@ impl AsRef for AbsolutePath { } } +impl Debug for AbsolutePath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut debug_tuple = f.debug_tuple("AbsolutePath"); + #[cfg(feature = "absolute-redaction")] + if let Some(redacted_path) = self.try_redact().unwrap() { + debug_tuple.field(&redacted_path); + return debug_tuple.finish(); + } + debug_tuple.field(&&self.0); + debug_tuple.finish() + } +} + impl Serialize for AbsolutePath { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { #[cfg(feature = "absolute-redaction")] - { - use redaction::REDACTION_PREFIX; - - if let Some(redaction_prefix) = REDACTION_PREFIX - .with(|redaction_prefix| redaction_prefix.borrow().as_ref().map(Arc::clone)) - { - match self.strip_prefix(redaction_prefix) { - Ok(Some(stripped_path)) => return stripped_path.serialize(serializer), - Err(strip_error) => { - return Err(serde::ser::Error::custom(format!( - "Failed to redact absolute path '{}': {}", - self.as_path().display(), - strip_error - ))); - } - Ok(None) => { /* continue to serialize full path */ } - } - } + if let Some(redacted_path) = self.try_redact().map_err(serde::ser::Error::custom)? { + return serializer.serialize_str(&redacted_path); } self.as_path().serialize(serializer) } @@ -94,6 +91,30 @@ impl AbsolutePath { if path.is_absolute() { Some(unsafe { Self::assume_absolute(path) }) } else { None } } + #[cfg(feature = "absolute-redaction")] + fn try_redact(&self) -> Result, String> { + use redaction::REDACTION_PREFIX; + + if let Some(redaction_prefix) = REDACTION_PREFIX + .with(|redaction_prefix| redaction_prefix.borrow().as_ref().map(Arc::clone)) + { + match self.strip_prefix(redaction_prefix) { + Ok(Some(stripped_path)) => { + return Ok(Some(format!("/{}", stripped_path.as_str()))); + } + Err(strip_error) => { + return Err(format!( + "Failed to redact absolute path '{}': {}", + self.as_path().display(), + strip_error + )); + } + Ok(None) => { /* continue to serialize full path */ } + } + } + Ok(None) + } + #[ref_cast_custom] pub(crate) unsafe fn assume_absolute(abs_path: &Path) -> &Self; @@ -188,7 +209,7 @@ impl AsRef for AbsolutePath { } /// An owned path buf that is guaranteed to be absolute -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct AbsolutePathBuf(PathBuf); impl From for Arc { diff --git a/crates/vite_task/Cargo.toml b/crates/vite_task/Cargo.toml index 25b22fd0..501965ea 100644 --- a/crates/vite_task/Cargo.toml +++ b/crates/vite_task/Cargo.toml @@ -19,7 +19,7 @@ bstr = { workspace = true } clap = { workspace = true, features = ["derive"] } compact_str = { workspace = true, features = ["serde"] } dashmap = { workspace = true } -derive_more = { workspace = true } +derive_more = { workspace = true, features = ["from"] } diff-struct = { workspace = true } fspy = { workspace = true } futures-core = { workspace = true } @@ -36,7 +36,8 @@ shell-escape = { workspace = true } supports-color = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread", "io-std", "macros"] } +tokio = { workspace = true, features = ["rt-multi-thread", "io-std", "macros", "sync"] } +tokio-stream = { workspace = true } tracing = { workspace = true } twox-hash = { workspace = true } uuid = { workspace = true, features = ["v4"] } @@ -51,10 +52,3 @@ wax = { workspace = true } [target.'cfg(unix)'.dependencies] nix = { workspace = true } - -[dev-dependencies] -copy_dir = { workspace = true } -insta = { workspace = true, features = ["glob", "json", "redactions"] } -tempfile = { workspace = true } -toml = { workspace = true } -vite_path = { workspace = true, features = ["absolute-redaction"] } diff --git a/crates/vite_task/src/bin/vite.rs b/crates/vite_task/src/bin/vite.rs deleted file mode 100644 index 42acd1ac..00000000 --- a/crates/vite_task/src/bin/vite.rs +++ /dev/null @@ -1,13 +0,0 @@ -use clap::Parser; -use vite_str::Str; -use vite_task::CLIArgs; - -#[derive(Debug, Parser)] -enum CustomTaskSubCommand { - /// oxlint - Lint { args: Vec }, -} - -fn main() { - let _subcommand = CLIArgs::::parse(); -} diff --git a/crates/vite_task/src/cache.rs b/crates/vite_task/src/cache.rs deleted file mode 100644 index 43fe7842..00000000 --- a/crates/vite_task/src/cache.rs +++ /dev/null @@ -1,283 +0,0 @@ -use std::{fmt::Display, io::Write, sync::Arc, time::Duration}; - -// use bincode::config::{Configuration, standard}; -use bincode::{Decode, Encode, decode_from_slice, encode_to_vec}; -use rusqlite::{Connection, OptionalExtension as _, config::DbConfig}; -use serde::{Deserialize, Serialize}; -use tokio::sync::Mutex; -use vite_path::{AbsolutePath, AbsolutePathBuf}; -use vite_str::Str; - -use crate::{ - Error, - config::{CommandFingerprint, ResolvedTask, TaskId}, - execute::{ExecutedTask, StdOutput}, - fingerprint::{PostRunFingerprint, PostRunFingerprintMismatch}, - fs::FileSystem, -}; - -/// Command cache value, for validating post-run fingerprint after the command fingerprint is matched, -/// and replaying the std outputs if validated. -#[derive(Debug, Encode, Decode, Serialize)] -pub struct CommandCacheValue { - pub post_run_fingerprint: PostRunFingerprint, - pub std_outputs: Arc<[StdOutput]>, - pub duration: Duration, -} - -impl CommandCacheValue { - pub fn create( - executed_task: ExecutedTask, - fs: &impl FileSystem, - base_dir: &AbsolutePath, - fingerprint_ignores: Option<&[Str]>, - ) -> Result { - let post_run_fingerprint = - PostRunFingerprint::create(&executed_task, fs, base_dir, fingerprint_ignores)?; - Ok(Self { - post_run_fingerprint, - std_outputs: executed_task.std_outputs, - duration: executed_task.duration, - }) - } -} - -#[derive(Debug)] -pub struct TaskCache { - conn: Mutex, - pub(crate) path: AbsolutePathBuf, -} - -/// Key to identify a task run. -/// It includes the additional args, so the same task with different args wouldn't overwrite each other. -#[derive(Debug, Encode, Decode, Serialize)] -pub struct TaskRunKey { - pub task_id: TaskId, - pub args: Arc<[Str]>, -} - -const BINCODE_CONFIG: bincode::config::Configuration = bincode::config::standard(); - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub enum CacheMiss { - NotFound, - FingerprintMismatch(FingerprintMismatch), -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub enum FingerprintMismatch { - /// Found the cache entry of the same task run, but the command fingerprint mismatches - /// this happens when the command itself or an env changes. - CommandFingerprintMismatch(CommandFingerprint), - /// Found the cache entry with the same command fingerprint, but the post-run fingerprint mismatches - PostRunFingerprintMismatch(PostRunFingerprintMismatch), -} - -impl Display for FingerprintMismatch { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::CommandFingerprintMismatch(diff) => { - // TODO: improve the display of command fingerprint diff - write!(f, "Command fingerprint changed: {diff:?}") - } - Self::PostRunFingerprintMismatch(diff) => Display::fmt(diff, f), - } - } -} - -impl TaskCache { - pub fn load_from_path(cache_path: AbsolutePathBuf) -> Result { - let path: &AbsolutePath = cache_path.as_ref(); - tracing::info!("Creating task cache directory at {:?}", path); - std::fs::create_dir_all(path)?; - - let db_path = path.join("cache.db"); - let conn = Connection::open(db_path.as_path())?; - conn.execute_batch("PRAGMA journal_mode=WAL;")?; - loop { - let user_version: u32 = conn.query_one("PRAGMA user_version", (), |row| row.get(0))?; - match user_version { - 0 => { - // fresh new db - conn.execute( - "CREATE TABLE command_cache (key BLOB PRIMARY KEY, value BLOB);", - (), - )?; - conn.execute( - "CREATE TABLE taskrun_to_command (key BLOB PRIMARY KEY, value BLOB);", - (), - )?; - // Bump to version 3 to invalidate cache entries due to a change in the serialized cache key content - // (addition of the `fingerprint_ignores` field). No schema change was made. - conn.execute("PRAGMA user_version = 3", ())?; - } - 1..=2 => { - // old internal db version. reset - conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, true)?; - conn.execute("VACUUM", ())?; - conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, false)?; - } - 3 => break, // current version - 4.. => return Err(Error::UnrecognizedDbVersion(user_version)), - } - } - Ok(Self { conn: Mutex::new(conn), path: cache_path }) - } - - #[tracing::instrument] - pub async fn save(self) -> Result<(), Error> { - // do some cleanup in the future - Ok(()) - } - - pub async fn update( - &self, - resolved_task: &ResolvedTask, - cached_task: CommandCacheValue, - ) -> Result<(), Error> { - let task_run_key = - TaskRunKey { task_id: resolved_task.id(), args: resolved_task.args.clone() }; - let command_fingerprint = &resolved_task.resolved_command.fingerprint; - self.upsert_command_cache(command_fingerprint, &cached_task).await?; - self.upsert_taskrun_to_command(&task_run_key, command_fingerprint).await?; - Ok(()) - } - - /// Tries to get the task cache if the fingerprint matches, otherwise returns why the cache misses - pub async fn try_hit( - &self, - task: &ResolvedTask, - fs: &impl FileSystem, - base_dir: &AbsolutePath, - ) -> Result, Error> { - let task_run_key = TaskRunKey { task_id: task.id(), args: task.args.clone() }; - let command_fingerprint = &task.resolved_command.fingerprint; - // Try to directly find the command cache by command fingerprint first, ignoring the task run key - if let Some(cache_value) = - self.get_command_cache_by_command_fingerprint(command_fingerprint).await? - { - if let Some(post_run_fingerprint_mismatch) = - cache_value.post_run_fingerprint.validate(fs, base_dir)? - { - // Found the command cache with the same command fingerprint, but the post-run fingerprint mismatches - Ok(Err(CacheMiss::FingerprintMismatch( - FingerprintMismatch::PostRunFingerprintMismatch(post_run_fingerprint_mismatch), - ))) - } else { - // Associate the task run key to the command fingerprint if not already, - // so that next time we can find it and report command fingerprint mismatch - self.upsert_taskrun_to_command(&task_run_key, command_fingerprint).await?; - Ok(Ok(cache_value)) - } - } else if let Some(command_fingerprint_in_cache) = - self.get_command_fingerprint_by_task_run_key(&task_run_key).await? - { - // No command cache found with the current command fingerprint, - // but found a command fingerprint associated with the same task run key, - // meaning the command or env has changed since last run - Ok(Err(CacheMiss::FingerprintMismatch( - FingerprintMismatch::CommandFingerprintMismatch(command_fingerprint_in_cache), - ))) - } else { - Ok(Err(CacheMiss::NotFound)) - } - } -} - -// basic database operations -impl TaskCache { - async fn get_key_by_value>( - &self, - table: &str, - key: &K, - ) -> Result, Error> { - let conn = self.conn.lock().await; - let mut select_stmt = - conn.prepare_cached(&format!("SELECT value FROM {table} WHERE key=?"))?; - let key_blob = encode_to_vec(key, BINCODE_CONFIG)?; - let Some(value_blob) = - select_stmt.query_row::, _, _>([key_blob], |row| row.get(0)).optional()? - else { - return Ok(None); - }; - let (value, _) = decode_from_slice::(&value_blob, BINCODE_CONFIG)?; - Ok(Some(value)) - } - - async fn get_command_cache_by_command_fingerprint( - &self, - command_fingerprint: &CommandFingerprint, - ) -> Result, Error> { - self.get_key_by_value("command_cache", command_fingerprint).await - } - - async fn get_command_fingerprint_by_task_run_key( - &self, - task_run_key: &TaskRunKey, - ) -> Result, Error> { - self.get_key_by_value("taskrun_to_command", task_run_key).await - } - - async fn upsert( - &self, - table: &str, - key: &K, - value: &V, - ) -> Result<(), Error> { - let conn = self.conn.lock().await; - let key_blob = encode_to_vec(key, BINCODE_CONFIG)?; - let value_blob = encode_to_vec(value, BINCODE_CONFIG)?; - let mut update_stmt = conn.prepare_cached(&format!( - "INSERT INTO {table} (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value=?2" - ))?; - update_stmt.execute([key_blob, value_blob])?; - Ok(()) - } - - async fn upsert_command_cache( - &self, - command_fingerprint: &CommandFingerprint, - cached_task: &CommandCacheValue, - ) -> Result<(), Error> { - self.upsert("command_cache", command_fingerprint, cached_task).await - } - - async fn upsert_taskrun_to_command( - &self, - task_run_key: &TaskRunKey, - command_fingerprint: &CommandFingerprint, - ) -> Result<(), Error> { - self.upsert("taskrun_to_command", task_run_key, command_fingerprint).await - } - - async fn list_table + Serialize, V: Decode<()> + Serialize>( - &self, - table: &str, - out: &mut impl Write, - ) -> Result<(), Error> { - let conn = self.conn.lock().await; - let mut select_stmt = conn.prepare_cached(&format!("SELECT key, value FROM {table}"))?; - let mut rows = select_stmt.query([])?; - while let Some(row) = rows.next()? { - let key_blob: Vec = row.get(0)?; - let value_blob: Vec = row.get(1)?; - let (key, _) = decode_from_slice::(&key_blob, BINCODE_CONFIG)?; - let (value, _) = decode_from_slice::(&value_blob, BINCODE_CONFIG)?; - writeln!( - out, - "{} => {}", - serde_json::to_string_pretty(&key)?, - serde_json::to_string_pretty(&value)? - )?; - } - Ok(()) - } - - pub async fn list(&self, mut out: impl Write) -> Result<(), Error> { - out.write_all(b"------- taskrun_to_command -------\n")?; - self.list_table::("taskrun_to_command", &mut out).await?; - out.write_all(b"------- command_cache -------\n")?; - self.list_table::("command_cache", &mut out).await?; - Ok(()) - } -} diff --git a/crates/vite_task/src/cli/mod.rs b/crates/vite_task/src/cli/mod.rs index 62b5736f..72b6afaa 100644 --- a/crates/vite_task/src/cli/mod.rs +++ b/crates/vite_task/src/cli/mod.rs @@ -1,25 +1,70 @@ -use std::sync::Arc; +use std::{ffi::OsStr, sync::Arc}; -use clap::Subcommand; +use clap::{Parser, Subcommand}; use vite_path::AbsolutePath; use vite_str::Str; use vite_task_graph::{TaskSpecifier, query::TaskQueryKind}; use vite_task_plan::plan_request::{PlanOptions, PlanRequest, QueryPlanRequest}; -#[derive(Debug, clap::Parser)] -pub enum CLIArgs { - /// subcommands provided by vite task - #[command(flatten)] - ViteTaskSubCommand(ViteTaskSubCommand), +/// Represents the CLI arguments handled by vite-task, including both built-in and custom subcommands. +#[derive(Debug)] +pub struct TaskCLIArgs { + pub(crate) original: Arc<[Str]>, + pub(crate) parsed: ParsedTaskCLIArgs, +} +pub enum CLIArgs { + /// vite-task's own built-in subcommands + Task(TaskCLIArgs), /// custom subcommands provided by vite+ - #[command(flatten)] - Custom(CustomSubCommand), + NonTask(NonTaskSubcommand), +} + +impl + CLIArgs +{ + /// Get the original CLI arguments + pub fn try_parse_from( + args: impl Iterator>, + ) -> Result { + #[derive(Debug, clap::Parser)] + enum ParsedCLIArgs { + /// subcommands handled by vite task + #[command(flatten)] + Task(ParsedTaskCLIArgs), + + /// subcommands that are not handled by vite task + #[command(flatten)] + NonTask(NonTaskSubcommand), + } + + let args = args.map(|arg| Str::from(arg.as_ref())).collect::>(); + let parsed_cli_args = ParsedCLIArgs::::try_parse_from( + args.iter().map(|s| OsStr::new(s.as_str())), + )?; + + Ok(match parsed_cli_args { + ParsedCLIArgs::Task(parsed_task_cli_args) => { + Self::Task(TaskCLIArgs { original: args, parsed: parsed_task_cli_args }) + } + ParsedCLIArgs::NonTask(non_task_subcommand) => Self::NonTask(non_task_subcommand), + }) + } +} + +#[derive(Debug, Parser)] +pub(crate) enum ParsedTaskCLIArgs { + /// subcommands provided by vite task, like `run` + #[clap(flatten)] + BuiltIn(BuiltInCommand), + /// custom subcommands provided by vite+, like `lint` + #[clap(flatten)] + Custom(CustomSubcommand), } /// vite task CLI subcommands #[derive(Debug, Subcommand)] -pub enum ViteTaskSubCommand { +pub(crate) enum BuiltInCommand { /// Run tasks Run { /// `packageName#taskName` or `taskName`. @@ -38,7 +83,7 @@ pub enum ViteTaskSubCommand { ignore_depends_on: bool, /// Additional arguments to pass to the tasks - #[clap(trailing_var_arg = true)] + #[clap(trailing_var_arg = true, allow_hyphen_values = true)] additional_args: Vec, }, } @@ -52,7 +97,7 @@ pub enum CLITaskQueryError { PackageNameSpecifiedWithRecursive { package_name: Str, task_name: Str }, } -impl ViteTaskSubCommand { +impl BuiltInCommand { /// Convert to `TaskQuery`, or return an error if invalid. pub fn into_plan_request( self, diff --git a/crates/vite_task/src/config/mod.rs b/crates/vite_task/src/config/mod.rs deleted file mode 100644 index bc07d54c..00000000 --- a/crates/vite_task/src/config/mod.rs +++ /dev/null @@ -1,284 +0,0 @@ -mod name; -mod task_command; -mod task_graph_builder; -mod workspace; - -use std::{ - collections::{BTreeMap, BTreeSet}, - ffi::OsStr, - future::Future, - sync::Arc, -}; - -use bincode::{Decode, Encode}; -use compact_str::ToCompactString; -use diff::Diff; -use serde::{Deserialize, Serialize}; -pub use task_command::*; -pub use task_graph_builder::*; -use vite_path::{self, RelativePath, RelativePathBuf}; -use vite_shell::TaskParsedCommand; -use vite_str::Str; -pub use workspace::*; - -use crate::{ - Error, - collections::{HashMap, HashSet}, - config::name::TaskName, - execute::TaskEnvs, - types::ResolveCommandResult, -}; - -#[derive(Encode, Decode, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Diff)] -#[diff(attr(#[derive(Debug)]))] -#[serde(rename_all = "camelCase")] -pub struct TaskConfig { - pub(crate) command: TaskCommand, - #[serde(default)] - pub(crate) cwd: RelativePathBuf, - pub(crate) cacheable: bool, - - #[serde(default)] - pub(crate) inputs: HashSet, - - #[serde(default)] - pub(crate) envs: HashSet, - - #[serde(default)] - pub(crate) pass_through_envs: HashSet, - - #[serde(default)] - pub(crate) fingerprint_ignores: Option>, -} - -impl TaskConfig { - pub fn set_fingerprint_ignores(&mut self, fingerprint_ignores: Option>) { - self.fingerprint_ignores = fingerprint_ignores; - } -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct TaskConfigWithDeps { - #[serde(flatten)] - pub(crate) config: TaskConfig, - #[serde(default)] - pub(crate) depends_on: Vec, -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct ViteTaskJson { - pub(crate) tasks: HashMap, -} - -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] -pub struct DisplayOptions { - /// Whether to hide the command ("~> echo hello") before the execution. - pub hide_command: bool, - - /// Whether to hide this task in the summary after all executions. - pub hide_summary: bool, - - /// If true, the task will not be replayed from the cache. - /// This is useful for tasks that should not be replayed, like auto run install command. - /// TODO: this is a temporary solution, we should find a better way to handle this. - pub ignore_replay: bool, -} - -/// A resolved task, ready to hit the cache or be executed -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ResolvedTask { - pub name: TaskName, - pub args: Arc<[Str]>, - pub resolved_config: ResolvedTaskConfig, - pub resolved_command: ResolvedTaskCommand, - pub display_options: DisplayOptions, -} - -impl ResolvedTask { - pub fn id(&self) -> TaskId { - TaskId { - subcommand_index: self.name.subcommand_index, - task_group_id: TaskGroupId { - task_group_name: self.name.task_group_name.clone(), - config_path: self.resolved_config.config_dir.clone(), - is_builtin: self.is_builtin(), - }, - } - } - - pub const fn is_builtin(&self) -> bool { - self.name.package_name.is_none() - } - - pub fn matches(&self, task_request: &str, current_package_path: Option<&RelativePath>) -> bool { - if self.name.subcommand_index.is_some() { - // never match non-last subcommand - return false; - } - - let Some(package_name) = &self.name.package_name else { - // never match built-in task - return false; - }; - - // match tasks in current package if the task_request doesn't contain '#' - if !task_request.contains('#') { - return current_package_path == Some(&self.resolved_config.config_dir) - && self.name.task_group_name == task_request; - } - - task_request.get(..package_name.len()) == Some(package_name) - && task_request.get(package_name.len()..=package_name.len()) == Some("#") - && task_request.get(package_name.len() + 1..) == Some(&self.name.task_group_name) - } - - /// For displaying in the UI. - /// Not necessarily a unique identifier as the package name can be duplicated. - pub fn display_name(&self) -> Str { - self.name.to_compact_string().into() - } - - #[tracing::instrument(skip(workspace, resolve_command, args))] - /// Resolve a built-in task, like `vite lint`, `vite build` - pub async fn resolve_from_builtin< - Resolved: Future>, - ResolveFn: Fn() -> Resolved, - >( - workspace: &Workspace, - resolve_command: ResolveFn, - task_name: &str, - args: impl Iterator> + Clone, - ) -> Result { - let ResolveCommandResult { bin_path, envs } = resolve_command().await?; - Self::resolve_from_builtin_with_command_result( - workspace, - task_name, - args, - ResolveCommandResult { bin_path, envs }, - false, - None, - ) - } - - pub fn resolve_from_builtin_with_command_result( - workspace: &Workspace, - task_name: &str, - args: impl Iterator> + Clone, - command_result: ResolveCommandResult, - ignore_replay: bool, - fingerprint_ignores: Option>, - ) -> Result { - let ResolveCommandResult { bin_path, envs } = command_result; - let builtin_task = TaskCommand::Parsed(TaskParsedCommand { - args: args.clone().map(|arg| arg.as_ref().into()).collect(), - envs: envs.into_iter().map(|(k, v)| (k.into(), v.into())).collect(), - program: bin_path.into(), - }); - let mut task_config: TaskConfig = builtin_task.clone().into(); - task_config.set_fingerprint_ignores(fingerprint_ignores.clone()); - let pass_through_envs = task_config.pass_through_envs.iter().cloned().collect(); - let cwd = &workspace.cwd; - let resolved_task_config = - ResolvedTaskConfig { config_dir: cwd.clone(), config: task_config }; - let resolved_envs = - TaskEnvs::resolve(std::env::vars_os(), &workspace.root_dir, &resolved_task_config)?; - let resolved_command = ResolvedTaskCommand { - fingerprint: CommandFingerprint { - cwd: cwd.clone(), - command: builtin_task, - envs_without_pass_through: resolved_envs - .envs_without_pass_through - .into_iter() - .collect(), - pass_through_envs, - fingerprint_ignores, - }, - all_envs: resolved_envs.all_envs, - }; - Ok(Self { - name: TaskName { - package_name: None, - task_group_name: task_name.into(), - subcommand_index: None, - }, - args: args.map(|arg| arg.as_ref().into()).collect(), - resolved_config: resolved_task_config, - resolved_command, - display_options: DisplayOptions { - // built-in tasks don't show the actual command. - // For example, `vite lint`'s actual command is the path to the bundled oxlint, - // We don't want to show that to the user. - // - // When built-in command like `vite lint` is run as the script of a user-defined task, the script itself - // will be displayed as the command in the inner runner. - hide_command: true, - hide_summary: false, - ignore_replay, - }, - }) - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct ResolvedTaskCommand { - pub fingerprint: CommandFingerprint, - pub all_envs: HashMap>, -} - -impl std::fmt::Debug for ResolvedTaskCommand { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if std::env::var("VITE_DEBUG_VERBOSE").map(|v| v != "0" && v != "false").unwrap_or(false) { - write!( - f, - "ResolvedTaskCommand {{ fingerprint: {:?}, all_envs: {:?} }}", - self.fingerprint, self.all_envs - ) - } else { - write!(f, "ResolvedTaskCommand {{ fingerprint: {:?} }}", self.fingerprint) - } - } -} - -/// Fingerprint for command execution that affects caching. -/// -/// # Environment Variable Impact on Cache -/// -/// The `envs_without_pass_through` field is crucial for cache correctness: -/// - Only includes envs explicitly declared in the task's `envs` array -/// - Does NOT include pass-through envs (PATH, CI, etc.) -/// - These envs become part of the cache key -/// -/// When a task runs: -/// 1. All envs (including pass-through) are available to the process -/// 2. Only declared envs affect the cache key -/// 3. If a declared env changes value, cache will miss -/// 4. If a pass-through env changes, cache will still hit -/// -/// For built-in tasks (lint, build, etc): -/// - The resolver provides envs which become part of the fingerprint -/// - If resolver provides different envs between runs, cache breaks -/// - Each built-in task type must have unique task name to avoid cache collision -/// -/// # Fingerprint Ignores Impact on Cache -/// -/// The `fingerprint_ignores` field controls which files are tracked in `PostRunFingerprint`: -/// - Changes to this config must invalidate the cache -/// - Vec maintains insertion order (pattern order matters for last-match-wins semantics) -/// - Even though ignore patterns only affect `PostRunFingerprint`, the config itself is part of the cache key -#[derive(Encode, Decode, Debug, Serialize, Deserialize, PartialEq, Eq, Diff, Clone)] -#[diff(attr(#[derive(Debug)]))] -pub struct CommandFingerprint { - pub cwd: RelativePathBuf, - pub command: TaskCommand, - /// Environment variables that affect caching (excludes pass-through envs) - pub envs_without_pass_through: BTreeMap, // using BTreeMap to have a stable order in cache db - - /// even though value changes to `pass_through_envs` shouldn't invalidate the cache, - /// The names should still be fingerprinted so that the cache can be invalidated if the `pass_through_envs` config changes - pub pass_through_envs: BTreeSet, // using BTreeSet to have a stable order in cache db - - /// Glob patterns for fingerprint filtering. Order matters (last match wins). - /// Changes to this config invalidate the cache to ensure correct fingerprint tracking. - pub fingerprint_ignores: Option>, -} diff --git a/crates/vite_task/src/config/name.rs b/crates/vite_task/src/config/name.rs deleted file mode 100644 index 641c18fd..00000000 --- a/crates/vite_task/src/config/name.rs +++ /dev/null @@ -1,43 +0,0 @@ -use std::fmt::Display; - -use serde::{Deserialize, Serialize}; -use vite_str::Str; - -/// For displaying and filtering tasks. -/// -/// Not suitable for uniquely identifying tasks as `package_name` isn't unique (may be duplicated and empty). -/// See `TaskId` for the unique identifier of a task. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TaskName { - /// The name of the script containing this task. - /// For example, in script `"build": "echo A && echo B"`, - /// Both task `echo A` and task `echo B` will have `task_group_name` = "build". - pub task_group_name: Str, - - /// The name of the package where this task is defined. - /// - /// - the value is `Some("")` if field `name` is not defined in a package - /// - the value is `None` the built-in task like `vite lint`, which doesn't belong to any package. - pub package_name: Option, - - /// The index of the subcommand in a parsed command (`echo A && echo B`). - /// `None` if the task is a main task, which is the last subcommand or the only subcommand in a script. - /// Only the main command can be matched against a user task request. - /// Non-main commands can only be included in the execution graph as main command's (in)direct dependencies. - pub subcommand_index: Option, -} - -impl Display for TaskName { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(package_name) = &self.package_name - && !package_name.is_empty() - { - write!(f, "{package_name}#")?; - } - write!(f, "{}", self.task_group_name)?; - if let Some(subcommand_index) = self.subcommand_index { - write!(f, "(subcommand {subcommand_index})")?; - } - Ok(()) - } -} diff --git a/crates/vite_task/src/config/task_command.rs b/crates/vite_task/src/config/task_command.rs deleted file mode 100644 index fa13df3d..00000000 --- a/crates/vite_task/src/config/task_command.rs +++ /dev/null @@ -1,115 +0,0 @@ -use std::fmt::Display; - -use bincode::{Decode, Encode}; -use diff::Diff; -use serde::{Deserialize, Serialize}; -use vite_path::{AbsolutePath, RelativePathBuf}; -use vite_shell::TaskParsedCommand; -use vite_str::Str; - -use super::{CommandFingerprint, ResolvedTaskCommand, TaskConfig}; -use crate::{Error, execute::TaskEnvs}; - -#[derive(Encode, Decode, Serialize, Deserialize, Debug, PartialEq, Eq, Diff, Clone)] -#[diff(attr(#[derive(Debug)]))] -#[serde(untagged)] -pub enum TaskCommand { - ShellScript(Str), - Parsed(TaskParsedCommand), -} - -impl Display for TaskCommand { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::ShellScript(command) => Display::fmt(&command, f), - Self::Parsed(parsed_command) => Display::fmt(&parsed_command, f), - } - } -} - -impl From for TaskConfig { - fn from(command: TaskCommand) -> Self { - Self { - command, - cwd: RelativePathBuf::empty(), - cacheable: true, - inputs: Default::default(), - envs: Default::default(), - pass_through_envs: Default::default(), - fingerprint_ignores: Default::default(), - } - } -} - -impl TaskCommand { - pub fn need_skip_cache(&self) -> bool { - matches!(self, Self::Parsed(parsed_command) if parsed_command.program == "vite" || (parsed_command.program.ends_with("vite.js") && parsed_command.args.first() == Some(&("dev".into())))) - } - - // Whether the command starts a inner runner. - pub fn has_inner_runner(&self) -> bool { - let Self::Parsed(parsed_command) = self else { - return false; - }; - if parsed_command.program != "vite" { - return false; - } - let Some(subcommand) = parsed_command.args.first() else { - return false; - }; - matches!(subcommand.as_str(), "run" | "lint" | "fmt" | "build" | "test" | "lib") - } -} - -#[derive(Encode, Decode, Debug, Serialize, Deserialize, PartialEq, Eq, Diff, Clone)] -#[diff(attr(#[derive(Debug)]))] -pub struct ResolvedTaskConfig { - pub config_dir: RelativePathBuf, - pub config: TaskConfig, -} - -impl ResolvedTaskConfig { - pub(crate) fn resolve_command( - &self, - base_dir: &AbsolutePath, - task_args: &[Str], - ) -> Result { - let cwd = self.config_dir.join(&self.config.cwd); - let command = if task_args.is_empty() { - self.config.command.clone() - } else { - match &self.config.command { - TaskCommand::ShellScript(command_script) => { - let command_script = - std::iter::once(command_script.clone()) - .chain(task_args.iter().map(|arg| { - shell_escape::escape(arg.as_str().into()).as_ref().into() - })) - .collect::>() - .join(" ") - .into(); - TaskCommand::ShellScript(command_script) - } - TaskCommand::Parsed(parsed_command) => { - let mut parsed_command = parsed_command.clone(); - parsed_command.args.extend_from_slice(task_args); - TaskCommand::Parsed(parsed_command) - } - } - }; - let task_envs = TaskEnvs::resolve(std::env::vars_os(), base_dir, self)?; - Ok(ResolvedTaskCommand { - fingerprint: CommandFingerprint { - cwd, - command, - envs_without_pass_through: task_envs - .envs_without_pass_through - .into_iter() - .collect(), - pass_through_envs: self.config.pass_through_envs.iter().cloned().collect(), - fingerprint_ignores: self.config.fingerprint_ignores.clone(), - }, - all_envs: task_envs.all_envs, - }) - } -} diff --git a/crates/vite_task/src/config/task_graph_builder.rs b/crates/vite_task/src/config/task_graph_builder.rs deleted file mode 100644 index 31736a5b..00000000 --- a/crates/vite_task/src/config/task_graph_builder.rs +++ /dev/null @@ -1,90 +0,0 @@ -use bincode::{Decode, Encode}; -use petgraph::stable_graph::{NodeIndex, StableDiGraph}; -use serde::Serialize; -use vite_path::RelativePathBuf; -use vite_str::Str; - -use super::ResolvedTask; -use crate::{ - Error, - collections::{HashMap, HashSet}, -}; - -/// Uniquely identifies a task group, which is a script in `package.json`, or an entry in `vite-task.json`. -/// -/// A task group can be parsed into one task or multiple tasks split by `&&` -#[derive(Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Clone, Encode, Decode, Serialize)] -pub struct TaskGroupId { - /// For user defined task, this is the name of the script or the entry in `vite-task.json`. - /// For built-in tasks, this is the command name. - pub task_group_name: Str, - - /// Whether this is a built-in task like `vite lint`. - pub is_builtin: bool, - - /// The path to the config file that defines this task group, relative to the workspace root. - /// - /// For built-in tasks, there's no config file. This value will be the cwd, - /// so that same built-in command running under different folders will be treated as different tasks. - pub config_path: RelativePathBuf, -} - -/// Uniquely identifies a task. -/// -/// Similar to `TaskName` but replaces `package_name` with `config_path` to ensure uniqueness. -#[derive(Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Clone, Encode, Decode, Serialize)] -pub struct TaskId { - pub task_group_id: TaskGroupId, - - /// The index of the subcommand in a parsed command (`echo A && echo B`). - /// None if the task is the last command. Only the last command can be filtered out by user task requests. - pub subcommand_index: Option, -} - -#[derive(Default, Debug, Clone)] -pub struct TaskGraphBuilder { - pub(crate) resolved_tasks_and_dep_ids_by_id: HashMap)>, -} - -impl TaskGraphBuilder { - pub(crate) fn add_task_with_deps( - &mut self, - task: ResolvedTask, - dep_ids: HashSet, - ) -> Result<(), Error> { - if let Some((old_task, _)) = - self.resolved_tasks_and_dep_ids_by_id.insert(task.id(), (task, dep_ids)) - { - return Err(Error::DuplicatedTask(old_task.display_name())); - } - Ok(()) - } - - /// Build the complete task graph including all tasks and their dependencies - pub(crate) fn build_complete_graph(self) -> Result, Error> { - let mut task_graph = StableDiGraph::::new(); - let mut node_indices_by_task_ids = HashMap::::new(); - - // Add all tasks to the graph - for (task_id, (resolved_task, _)) in &self.resolved_tasks_and_dep_ids_by_id { - let node_index = task_graph.add_node(resolved_task.clone()); - node_indices_by_task_ids.insert(task_id.clone(), node_index); - } - - // Add edges from explicit dependencies - for (task_id, (_, deps)) in &self.resolved_tasks_and_dep_ids_by_id { - let current_task_index = node_indices_by_task_ids[task_id]; - for dep in deps { - let Some(&dep_index) = node_indices_by_task_ids.get(dep) else { - return Err(Error::TaskDependencyNotFound { - name: dep.task_group_id.task_group_name.clone(), - package_path: dep.task_group_id.config_path.clone(), - }); - }; - task_graph.add_edge(current_task_index, dep_index, ()); - } - } - - Ok(task_graph) - } -} diff --git a/crates/vite_task/src/config/workspace.rs b/crates/vite_task/src/config/workspace.rs deleted file mode 100644 index a14b1ddf..00000000 --- a/crates/vite_task/src/config/workspace.rs +++ /dev/null @@ -1,688 +0,0 @@ -use std::{ - collections::{BTreeSet, hash_map::Entry}, - fs::File, - io::BufReader, - sync::Arc, -}; - -use petgraph::{ - graph::{DiGraph, NodeIndex}, - stable_graph::StableDiGraph, - visit::IntoNodeReferences, -}; -use vite_path::{AbsolutePath, AbsolutePathBuf, RelativePath, RelativePathBuf}; -use vite_shell::try_parse_as_and_list; -use vite_str::Str; -use vite_workspace::{ - DependencyType, PackageInfo, PackageIx, PackageJson, PackageNodeIndex, WorkspaceRoot, - find_package_root, find_workspace_root, -}; - -use super::{ - ResolvedTask, ResolvedTaskConfig, TaskCommand, TaskConfig, TaskGraphBuilder, TaskId, - ViteTaskJson, -}; -use crate::{ - Error, - cache::TaskCache, - collections::{HashMap, HashSet}, - config::{DisplayOptions, TaskGroupId, name::TaskName}, - fs::CachedFileSystem, -}; - -#[derive(Debug)] -pub struct Workspace { - pub(crate) root_dir: AbsolutePathBuf, - pub(crate) cwd: RelativePathBuf, - /// Relative path from workspace root to current package directory. - /// Empty string ("") represents the workspace root package itself. - /// None indicates that it cannot find the package root from the current directory.. - /// This allows distinguishing between workspace-level tasks and package-level tasks. - pub(crate) current_package_path: Option, - pub(crate) task_cache: TaskCache, - pub(crate) fs: CachedFileSystem, - pub(crate) package_graph: DiGraph, - #[expect(unused)] - pub(crate) package_json: PackageJson, - pub(crate) task_graph: StableDiGraph, -} - -impl Workspace { - /// Determines the current package path relative to the workspace root. - /// Returns (workspace root, cwd relative to workspace root, current package root relative to workspace root). - fn determine_current_package_path( - original_cwd: &AbsolutePath, - ) -> Result<(Arc, RelativePathBuf, Option), Error> { - let (WorkspaceRoot { path: workspace_root, .. }, cwd) = find_workspace_root(original_cwd)?; - // current package root is None if it can't be found - let Ok(package_root) = find_package_root(original_cwd) else { - return Ok((workspace_root, cwd, None)); - }; - let current_package_root = package_root.path; - - // Get relative path from workspace root to package root - let current_package_root = current_package_root.strip_prefix(&*workspace_root)?; - Ok((workspace_root, cwd, current_package_root)) - } - - #[tracing::instrument] - pub fn load(cwd: AbsolutePathBuf, topological_run: bool) -> Result { - Self::load_with_cache_path(cwd, None, topological_run) - } - - pub fn partial_load(cwd: AbsolutePathBuf) -> Result { - Self::partial_load_with_cache_path(cwd, None) - } - - fn get_cache_path_of_workspace(workspace_root: &AbsolutePath) -> AbsolutePathBuf { - if let Ok(env_cache_path) = std::env::var("VITE_CACHE_PATH") { - AbsolutePathBuf::new(env_cache_path.into()).expect("Cache path should be absolute") - } else { - workspace_root.join("node_modules/.vite/task-cache") - } - } - - pub fn get_cache_path(cwd: &AbsolutePath) -> Result { - let (workspace_root, _, _) = Self::determine_current_package_path(cwd)?; - Ok(Self::get_cache_path_of_workspace(&workspace_root)) - } - - pub fn partial_load_with_cache_path( - cwd: AbsolutePathBuf, - cache_path: Option, - ) -> Result { - // Determine current package path relative to workspace root - let (workspace_root, cwd, current_package_path) = - Self::determine_current_package_path(&cwd)?; - - let cache_path = - cache_path.unwrap_or_else(|| Self::get_cache_path_of_workspace(&workspace_root)); - - if !cache_path.as_path().exists() - && let Some(cache_dir) = cache_path.as_path().parent() - { - tracing::info!("Creating task cache directory at {}", cache_dir.display()); - std::fs::create_dir_all(cache_dir)?; - } - let task_cache = TaskCache::load_from_path(cache_path)?; - - let package_json_path = workspace_root.join("package.json"); - let package_json = if package_json_path.as_path().exists() { - let file = File::open(package_json_path.as_path())?; - let reader = BufReader::new(file); - serde_json::from_reader(reader)? - } else { - PackageJson::default() - }; - - Ok(Self { - package_graph: Default::default(), - root_dir: workspace_root.to_absolute_path_buf(), - cwd, - current_package_path, - task_cache, - fs: CachedFileSystem::default(), - package_json, - task_graph: StableDiGraph::new(), - }) - } - - pub fn load_with_cache_path( - cwd: AbsolutePathBuf, - cache_path: Option, - topological_run: bool, - ) -> Result { - // Determine current package path relative to workspace root - let (workspace_root, cwd, current_package_path) = - Self::determine_current_package_path(&cwd)?; - - let package_graph = vite_workspace::discover_package_graph(&*workspace_root)?; - // Load vite-task.json files for all packages - let packages_with_task_jsons = Self::load_vite_task_jsons(&package_graph, &workspace_root)?; - - // Find root package.json - let mut package_json = None; - for node_index in package_graph.node_indices() { - let package = &package_graph[node_index]; - if package.path.as_str().is_empty() { - package_json = Some(package.package_json.clone()); - break; - } - } - - let cache_path = - cache_path.unwrap_or_else(|| Self::get_cache_path_of_workspace(&workspace_root)); - - if !cache_path.as_path().exists() - && let Some(cache_dir) = cache_path.as_path().parent() - { - tracing::info!("Creating task cache directory at {}", cache_dir.display()); - std::fs::create_dir_all(cache_dir)?; - } - let task_cache = TaskCache::load_from_path(cache_path)?; - - // Build the complete task graph - let mut task_graph_builder = TaskGraphBuilder::default(); - - // Create a map from package name to node index for efficient lookups - // The values are Vecs because multiple packages can have the same name. - let mut package_path_to_node = - HashMap::>::with_capacity(package_graph.node_count()); - for (package_node_index, package) in package_graph.node_references() { - package_path_to_node - .entry(package.package_json.name.clone()) - .or_default() - .push(package_node_index); - } - - // Load all tasks into the builder - Self::load_tasks_into_builder( - &packages_with_task_jsons, - &package_graph, - &package_path_to_node, - &mut task_graph_builder, - &workspace_root, - )?; - - // Add topological dependencies if enabled - if topological_run { - Self::add_topological_dependencies(&mut task_graph_builder, &package_graph); - } - - // Build the complete task graph with all dependencies - let task_graph = task_graph_builder.build_complete_graph()?; - - Ok(Self { - package_graph, - root_dir: workspace_root.to_absolute_path_buf(), - cwd, - current_package_path, - task_cache, - fs: CachedFileSystem::default(), - package_json: package_json.unwrap_or_default(), - task_graph, - }) - } - - pub const fn cache(&self) -> &TaskCache { - &self.task_cache - } - - pub const fn cache_path(&self) -> &AbsolutePathBuf { - &self.task_cache.path - } - - pub const fn root_dir(&self) -> &AbsolutePathBuf { - &self.root_dir - } - - pub async fn unload(self) -> Result<(), Error> { - tracing::debug!("Saving task cache {}", self.root_dir.as_path().display()); - self.task_cache.save().await?; - Ok(()) - } - - /// Resolve a task configuration into a `ResolvedTask` when building the task graph. - fn resolve_task( - user_task_config: impl Into, - package_info: &PackageInfo, - name: Str, - subcommand_index: Option, - base_dir: &AbsolutePath, - ) -> Result { - let resolved_config = ResolvedTaskConfig { - config_dir: package_info.path.clone(), - config: user_task_config.into(), - }; - - let resolved_command = resolved_config.resolve_command(base_dir, &[])?; - Ok(ResolvedTask { - name: TaskName { - task_group_name: name, - package_name: package_info.package_json.name.clone().into(), - subcommand_index, - }, - // additional args are not part of full task graph, they will be added when constructing subgraph for execution - args: Arc::default(), - resolved_command, - resolved_config, - display_options: DisplayOptions::default(), - }) - } - - /// Constructs a dependency graph of subtasks from the tasks that need to be executed. - /// - /// ## Task Resolution Process - /// - /// ### Example: `vite-plus run build --recursive --topological` - /// - /// Package structure: - /// ```no_compile - /// @test/core (no deps) - /// @test/utils (depends on @test/core) - /// @test/app (depends on @test/utils) - /// @test/web (depends on @test/app, @test/core) - /// ``` - /// - /// ### Step 1: Collect all tasks from packages - /// - For each package, find tasks from: - /// - vite-task.json (custom task definitions) - /// - package.json scripts - /// - If script contains `&&`, split into subtasks: - /// - `"build": "echo a && echo b && echo c"` becomes: - /// - `pkg#build` (`subcommand_index`: Some(0)) -> "echo a" - /// - `pkg#build` (`subcommand_index`: Some(1)) -> "echo b" - /// - `pkg#build` (`subcommand_index`: None) -> "echo c" - /// - /// ### Step 2: Build dependency graph - /// - /// #### Without --topological: - /// ```no_compile - /// @test/utils#build: - /// [0] ──► [1] ──► [None] - /// (subtasks depend on each other within package) - /// ``` - /// - /// #### With --recursive --topological: - /// ```no_compile - /// @test/core#build ───┐ - /// ▼ - /// @test/utils#build: [0] ──► [1] ──► [None] - /// │ - /// ▼ - /// @test/app#build - /// │ - /// ┌───────────────────────────────┘ - /// ▼ - /// @test/web#build - /// ``` - /// - /// Cross-package dependencies rules: - /// - FIRST subtask (or None) depends on LAST subtask of dependencies - /// - Dependent packages depend on THIS package's LAST subtask - #[tracing::instrument(skip(self))] - pub fn build_task_subgraph( - &self, - task_requests: &[Str], - task_args: Arc<[Str]>, - recursive_run: bool, - ) -> Result, Error> { - if recursive_run { - for task in task_requests { - if task.contains('#') { - return Err(Error::RecursiveRunWithScope(task.clone())); - } - } - } - - let mut remaining_task_node_indexes: BTreeSet = BTreeSet::new(); - - if recursive_run { - // When recursive, find all packages that have the requested tasks - // TODO(feat): only search the dependencies of the cwd package. - for task_request in task_requests { - for node_index in self.package_graph.node_indices() { - let package = &self.package_graph[node_index]; - let task_id_to_match = TaskId { - task_group_id: TaskGroupId { - task_group_name: task_request.clone(), - config_path: package.path.clone(), - is_builtin: false, - }, - // Starts with the main command only. The subcommands before the main command will be included later as dependencies. - subcommand_index: None, - }; - for (task_node_index, task) in self.task_graph.node_references() { - if task.id() == task_id_to_match { - remaining_task_node_indexes.insert(task_node_index); - } - } - } - } - } else { - // Only one task request is allowed when task requests don't contain '#' - if task_requests.iter().any(|task| !task.contains('#')) && task_requests.len() > 1 { - return Err(Error::OnlyOneTaskRequest(task_requests.join(" ").into())); - } - // For non-recursive mode, find the task in the full task graph - for task_request in task_requests { - let mut has_matched_task = false; - for (task_node_index, task) in self.task_graph.node_references() { - if task.matches(task_request, self.current_package_path.as_deref()) { - has_matched_task = true; - remaining_task_node_indexes.insert(task_node_index); - } - } - if !has_matched_task { - return Err(Error::TaskNotFound { task_request: task_request.clone() }); - } - } - } - - // Build a filtered graph from the pre-built task graph. - - // Map from node indexes (in the full graph and will be used in the subgraph) to tasks updated with additional args - let mut filtered_tasks_by_node_index = HashMap::::new(); - - while let Some(task_node_index) = remaining_task_node_indexes.pop_first() { - let Entry::Vacant(vacant_entry) = filtered_tasks_by_node_index.entry(task_node_index) - else { - continue; - }; - - let mut updated_task = self.task_graph[task_node_index].clone(); - - // Update task args if provided - assert!( - updated_task.args.is_empty(), - "Pre-built tasks in the full task graph should not contain additional args" - ); - if !task_args.is_empty() { - // This is needed for constructing the task run key for caching, so that different args lead to different task runs. - updated_task.args = task_args.clone(); - updated_task.resolved_command = - updated_task.resolved_config.resolve_command(&self.root_dir, &task_args)?; - } - - // Add to filtered graph - vacant_entry.insert(updated_task); - - // Add dependencies to the queue - for dependency_task_node_index in self.task_graph.neighbors(task_node_index) { - remaining_task_node_indexes.insert(dependency_task_node_index); - } - } - // Map from the full task graph so that the node indexes are unchanged. - // The consistency of node indexes between the full graph and the subgraph will make it easier to render the subgraph in UI. - let filtered_graph = self.task_graph.filter_map( - |node_index, _| filtered_tasks_by_node_index.remove(&node_index), - |_, ()| Some(()), // All edges between filtered tasks are preserved. - ); - Ok(filtered_graph) - } - - /// Load tasks from all packages into the task graph builder - fn load_tasks_into_builder( - packages_with_task_jsons: &[(PackageNodeIndex, Option)], - package_graph: &DiGraph, - package_name_to_node: &HashMap>, - task_graph_builder: &mut TaskGraphBuilder, - base_dir: &AbsolutePath, - ) -> Result<(), Error> { - for (package_node_index, task_json) in packages_with_task_jsons { - let package_info = &package_graph[*package_node_index]; - // Load tasks from vite-task.json - if let Some(task_json) = task_json { - for (task_group_name, task_config_json) in &task_json.tasks { - let resolved_task = Self::resolve_task( - task_config_json.config.clone(), - package_info, - task_group_name.clone(), - None, - base_dir, - )?; - - // Parsing each dependency request (pkg#taskname or taskname) into TaskId. - let deps: HashSet = task_config_json - .depends_on - .iter() - .cloned() - .map(|task_request| { - let sharp_pos = task_request.find('#'); - if sharp_pos == task_request.rfind('#') { - let (dep_package_node_index, dep_task_name): ( - PackageNodeIndex, - Str, - ) = if let Some(sharp_pos) = sharp_pos { - let package_name = &task_request[..sharp_pos]; - let package_node_indexes = package_name_to_node - .get(package_name) - .ok_or_else(|| Error::TaskNotFound { - task_request: task_request.clone(), - })?; - match package_node_indexes.as_slice() { - [] => { - return Err(Error::PackageNotFound( - package_name.into(), - )); - } - [package_node_index] => ( - *package_node_index, - task_request[sharp_pos + 1..].into(), - ), - // Found more than one package with the same name - [package_node_index1, package_node_index2, ..] => { - return Err(Error::DuplicatedPackageName { - name: package_name.into(), - path1: package_graph[*package_node_index1] - .path - .clone(), - path2: package_graph[*package_node_index2] - .path - .clone(), - }); - } - } - } else { - // No '#' means it's a local task reference within the same package - (*package_node_index, task_request) - }; - - Ok(TaskId { - task_group_id: TaskGroupId { - task_group_name: dep_task_name, - is_builtin: false, - config_path: package_graph[dep_package_node_index] - .path - .clone(), - }, - subcommand_index: None, // Always points to the main task - }) - } else { - // contains multiple '#' - Err(Error::AmbiguousTaskRequest { task_request }) - } - }) - .collect::, Error>>()?; - - task_graph_builder.add_task_with_deps(resolved_task, deps)?; - } - } - - // Load tasks from package.json scripts - for (script_name, script) in &package_info.package_json.scripts { - let script_name = script_name.as_str(); - - if let Some(and_list) = try_parse_as_and_list(script) { - let and_list_len = and_list.len(); - for (index, (command, _)) in and_list.into_iter().enumerate() { - let is_last = index + 1 == and_list_len; - - let resolved_task = Self::resolve_task( - TaskCommand::Parsed(command), - package_info, - script_name.into(), - if is_last { None } else { Some(index) }, - base_dir, - )?; - let task_id = resolved_task.id(); - let deps = if let Some(dep_index) = index.checked_sub(1) { - HashSet::from([TaskId { subcommand_index: Some(dep_index), ..task_id }]) - } else { - HashSet::default() - }; - task_graph_builder.add_task_with_deps(resolved_task, deps)?; - } - } else { - let resolved_task = Self::resolve_task( - TaskCommand::ShellScript(script.as_str().into()), - package_info, - script_name.into(), - None, - base_dir, - )?; - task_graph_builder.add_task_with_deps(resolved_task, HashSet::default())?; - } - } - } - Ok(()) - } - - /// Add topological dependencies to the task graph builder - fn add_topological_dependencies( - task_graph_builder: &mut TaskGraphBuilder, - package_graph: &DiGraph, - ) { - let package_path_to_node_index = package_graph - .node_references() - .map(|(node_index, package)| (package.path.as_relative_path(), node_index)) - .collect::>(); - - // Collect all tasks grouped by task group id - let mut task_ids_by_task_group_id: HashMap> = - HashMap::default(); - - // Iterate through all tasks in the graph builder to collect them - for task_id in task_graph_builder.resolved_tasks_and_dep_ids_by_id.keys() { - // Extract package name and task name from the task_id - - // Determine the order/index for subtasks - let order = match task_id.subcommand_index { - None => usize::MAX, // Use MAX for the last/main task - Some(idx) => idx, - }; - - task_ids_by_task_group_id - .entry(task_id.task_group_id.clone()) - .or_default() - .push((task_id.clone(), order)); - } - - // Sort tasks within each group by their order - for tasks in task_ids_by_task_group_id.values_mut() { - tasks.sort_by_key(|(_, order)| *order); - } - - // Add topological dependencies - for (task_group_id, current_tasks) in &task_ids_by_task_group_id { - let package_path = task_group_id.config_path.as_relative_path(); - let task_group_name = &task_group_id.task_group_name; - // Find the FIRST subtask of the current package (or the only task if no subtasks) - let first_current_task = current_tasks.first().map(|(task_id, _)| task_id); - - if let Some(first_task) = first_current_task { - // Only add dependencies to the FIRST subtask - if first_task.subcommand_index.is_none() || first_task.subcommand_index == Some(0) { - // Find all transitive dependencies of this package - let transitive_deps = find_transitive_dependencies( - package_path, - package_graph, - &package_path_to_node_index, - ); - - // For each dependency package, find its tasks with the same name - let mut additional_deps = Vec::new(); - for dep_package_path in transitive_deps { - if let Some(dep_tasks) = task_ids_by_task_group_id.get(&TaskGroupId { - is_builtin: false, - task_group_name: task_group_name.clone(), - config_path: dep_package_path, - }) { - // Find the LAST subtask of the dependency (highest order) - if let Some((last_dep_task, _)) = dep_tasks.last() { - additional_deps.push(last_dep_task.clone()); - } - } - } - - // Update the task graph builder with additional dependencies - if !additional_deps.is_empty() - && let Some((_task, deps)) = - task_graph_builder.resolved_tasks_and_dep_ids_by_id.get_mut(first_task) - { - deps.extend(additional_deps); - } - } - } - } - } - - /// Load vite-task.json files for all packages - fn load_vite_task_jsons( - package_graph: &DiGraph, - base_dir: &AbsolutePath, - ) -> Result)>, Error> { - let mut packages_with_task_jsons = Vec::new(); - - for node_idx in package_graph.node_indices() { - let package = &package_graph[node_idx]; - let vite_task_json_path = base_dir.join(&package.path).join("vite-task.json"); - let vite_task_json: Option = - match File::open(vite_task_json_path.as_path()) { - Ok(vite_task_json_file) => { - Some(serde_json::from_reader(BufReader::new(vite_task_json_file))?) - } - Err(err) => { - if err.kind() != std::io::ErrorKind::NotFound { - return Err(err.into()); - } - None - } - }; - packages_with_task_jsons.push((node_idx, vite_task_json)); - } - - Ok(packages_with_task_jsons) - } -} - -/// Find paths of all transitive dependencies of a package -fn find_transitive_dependencies( - package_path: &RelativePath, - package_graph: &DiGraph, - package_path_to_node_index: &HashMap<&RelativePath, PackageNodeIndex>, -) -> Vec { - let mut result = Vec::new(); - let mut visited = HashSet::default(); - - find_transitive_dependencies_recursive( - package_path, - package_graph, - package_path_to_node_index, - &mut visited, - &mut result, - ); - - result -} - -fn find_transitive_dependencies_recursive<'a>( - package_path: &'a RelativePath, - package_graph: &'a DiGraph, - package_path_to_node: &HashMap<&'a RelativePath, PackageNodeIndex>, - visited: &mut HashSet<&'a RelativePath>, - result: &mut Vec, -) { - if visited.contains(package_path) { - return; - } - visited.insert(package_path); - - // Find the package in the graph - if let Some(&node_idx) = package_path_to_node.get(package_path) { - // Check all dependencies from the package from - for dep_index in package_graph.neighbors(node_idx) { - let dep_path = &package_graph[dep_index].path; - result.push(dep_path.clone()); - - // Continue searching transitively - find_transitive_dependencies_recursive( - dep_path, - package_graph, - package_path_to_node, - visited, - result, - ); - } - } -} diff --git a/crates/vite_task/src/error.rs b/crates/vite_task/src/error.rs deleted file mode 100644 index 7ea49bcd..00000000 --- a/crates/vite_task/src/error.rs +++ /dev/null @@ -1,117 +0,0 @@ -use std::{ffi::OsString, io, path::Path}; - -use fspy::error::SpawnError; -use petgraph::algo::Cycle; -use vite_path::{ - RelativePathBuf, - absolute::StripPrefixError, - relative::{FromPathError, InvalidPathDataError}, -}; -use vite_str::Str; - -#[derive(Debug, thiserror::Error)] -pub enum Error { - // Task-specific errors (constructed in vite_task only) - #[error("Duplicate package name `{name}` found at `{path1}` and `{path2}`")] - DuplicatedPackageName { name: Str, path1: RelativePathBuf, path2: RelativePathBuf }, - - #[error("Package not found in workspace: `{0}`")] - PackageNotFound(Str), - - #[error("Duplicate task: `{0}`")] - DuplicatedTask(Str), - - #[error("Cycle dependencies detected: {0:?}")] - CycleDependencies(Cycle), - - #[error("Task not found: `{task_request}`")] - TaskNotFound { task_request: Str }, - - #[error("Task dependency `{name}` not found in package at `{package_path}`")] - TaskDependencyNotFound { name: Str, package_path: RelativePathBuf }, - - #[error("Ambiguous task request: `{task_request}` (contains multiple '#')")] - AmbiguousTaskRequest { task_request: Str }, - - #[error("Only one task is allowed in implicit mode (got: `{0}`)")] - OnlyOneTaskRequest(Str), - - #[error("Recursive run with scoped task name is not supported: `{0}`")] - RecursiveRunWithScope(Str), - - // Errors used by vite_task but not task-specific - #[error("Unrecognized db version: {0}")] - UnrecognizedDbVersion(u32), - - #[error("Env value is not valid unicode: {key} = {value:?}")] - EnvValueIsNotValidUnicode { key: Str, value: OsString }, - - #[error( - "The stripped path ({stripped_path:?}) is not a valid relative path because: {invalid_path_data_error}" - )] - StripPath { stripped_path: Box, invalid_path_data_error: InvalidPathDataError }, - - #[error("The path ({path:?}) is not a valid relative path because: {reason}")] - InvalidRelativePath { path: Box, reason: FromPathError }, - - #[cfg(unix)] - #[error("Unsupported file type: {0:?}")] - UnsupportedFileType(nix::dir::Type), - - #[cfg(windows)] - #[error("Unsupported file type: {0:?}")] - UnsupportedFileType(std::fs::FileType), - - // External library errors - #[error(transparent)] - Io(#[from] io::Error), - - #[error(transparent)] - JoinPathsError(#[from] std::env::JoinPathsError), - - #[error(transparent)] - WaxBuild(#[from] wax::BuildError), - - #[error(transparent)] - WaxWalk(#[from] wax::WalkError), - - #[error(transparent)] - Utf8Error(#[from] bstr::Utf8Error), - - #[error(transparent)] - Serde(#[from] serde_json::Error), - - #[error(transparent)] - Sqlite(#[from] rusqlite::Error), - - #[error(transparent)] - BincodeEncode(#[from] bincode::error::EncodeError), - - #[error(transparent)] - BincodeDecode(#[from] bincode::error::DecodeError), - - #[error(transparent)] - Anyhow(#[from] anyhow::Error), - - #[error(transparent)] - Glob(#[from] vite_glob::Error), - - #[error(transparent)] - Workspace(#[from] vite_workspace::Error), - - #[cfg(unix)] - #[error(transparent)] - Nix(#[from] nix::Error), - - #[error("Failed to spawn task")] - SpawnError(#[from] SpawnError), -} - -impl From> for Error { - fn from(value: StripPrefixError<'_>) -> Self { - Self::StripPath { - stripped_path: Box::from(value.stripped_path), - invalid_path_data_error: value.invalid_path_data_error, - } - } -} diff --git a/crates/vite_task/src/execute.rs b/crates/vite_task/src/execute.rs deleted file mode 100644 index da950f0f..00000000 --- a/crates/vite_task/src/execute.rs +++ /dev/null @@ -1,1080 +0,0 @@ -use std::{ - collections::hash_map::Entry, - env::{join_paths, split_paths}, - ffi::{OsStr, OsString}, - path::PathBuf, - process::{ExitStatus, Stdio}, - sync::{Arc, LazyLock, Mutex}, - time::{Duration, Instant}, -}; - -use bincode::{Decode, Encode}; -use fspy::AccessMode; -use futures_util::future::try_join3; -use itertools::Itertools; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use supports_color::{Stream, on}; -use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _}; -use vite_glob::GlobPatternSet; -use vite_path::{AbsolutePath, RelativePathBuf}; -use vite_str::Str; -use wax::Glob; - -use crate::{ - Error, - collections::{HashMap, HashSet}, - config::{ResolvedTask, ResolvedTaskCommand, ResolvedTaskConfig, TaskCommand}, - maybe_str::MaybeString, -}; - -#[derive(Debug, PartialEq, Eq, Clone, Copy, Encode, Decode, Serialize, Deserialize)] -pub enum OutputKind { - StdOut, - StdErr, -} - -#[derive(Debug, Encode, Decode, Serialize)] -pub struct StdOutput { - pub kind: OutputKind, - pub content: MaybeString, -} - -#[derive(Debug, Clone, Copy)] -pub struct PathRead { - pub read_dir_entries: bool, -} - -#[derive(Debug, Clone, Copy)] -pub struct PathWrite; - -/// Contains info that is available after executing the task -#[derive(Debug)] -pub struct ExecutedTask { - pub std_outputs: Arc<[StdOutput]>, - pub exit_status: ExitStatus, - pub path_reads: HashMap, - pub path_writes: HashMap, - pub duration: Duration, -} - -/// Collects stdout/stderr into `outputs` and at the same time writes them to the real stdout/stderr -async fn collect_std_outputs( - outputs: &Mutex>, - mut stream: impl AsyncRead + Unpin, - kind: OutputKind, -) -> Result<(), Error> { - let mut buf = [0u8; 8192]; - let mut parent_output_handle: Box = match kind { - OutputKind::StdOut => Box::new(tokio::io::stdout()), - OutputKind::StdErr => Box::new(tokio::io::stderr()), - }; - loop { - let n = stream.read(&mut buf).await?; - if n == 0 { - return Ok(()); - } - let content = &buf[..n]; - parent_output_handle.write_all(content).await?; - parent_output_handle.flush().await?; - let mut outputs = outputs.lock().unwrap(); - if let Some(last) = outputs.last_mut() - && last.kind == kind - { - last.content.extend_from_slice(content); - } else { - outputs.push(StdOutput { kind, content: content.to_vec().into() }); - } - } -} - -/// Environment variables for task execution. -/// -/// # How Environment Variables Affect Caching -/// -/// Vite-plus distinguishes between two types of environment variables: -/// -/// 1. **Declared envs** (in task config's `envs` array): -/// - Explicitly declared as dependencies of the task -/// - Included in `envs_without_pass_through` -/// - Changes to these invalidate the cache -/// - Example: `NODE_ENV`, `API_URL`, `BUILD_MODE` -/// -/// 2. **Pass-through envs** (in task config's `pass_through_envs` or defaults like PATH): -/// - Available to the task but don't affect caching -/// - Only in `all_envs`, NOT in `envs_without_pass_through` -/// - Changes to these don't invalidate cache -/// - Example: PATH, HOME, USER, CI -/// -/// ## Cache Key Generation -/// - Only `envs_without_pass_through` is included in the cache key -/// - This ensures tasks are re-run when important envs change -/// - But allows cache reuse when only incidental envs change -/// -/// ## Common Issues -/// - If a built-in resolver provides different envs, cache will be polluted -/// - Missing important envs from `envs` array = stale cache on env changes -/// - Including volatile envs in `envs` array = unnecessary cache misses -#[derive(Debug)] -pub struct TaskEnvs { - /// All environment variables available to the task (declared + pass-through) - pub all_envs: HashMap>, - /// Only declared envs that affect the cache key (excludes pass-through) - pub envs_without_pass_through: HashMap, -} - -fn resolve_envs_with_patterns( - env_vars: impl Iterator, - patterns: &[&str], -) -> Result>, Error> { - let patterns = GlobPatternSet::new(patterns.iter().filter(|pattern| { - if pattern.starts_with('!') { - // FIXME: use better way to print warning log - // Or parse and validate TaskConfig in command parsing phase - tracing::warn!( - "env pattern starts with '!' is not supported, will be ignored: {}", - pattern - ); - false - } else { - true - } - }))?; - let envs: HashMap> = env_vars - .filter_map(|(name, value)| { - let Some(name) = name.to_str() else { - return None; - }; - - if patterns.is_match(name) { - Some((Str::from(name), Arc::::from(value))) - } else { - None - } - }) - .collect(); - Ok(envs) -} - -// Exact matches for common environment variables -// Referenced from Turborepo's implementation: -// https://github.com/vercel/turborepo/blob/26d309f073ca3ac054109ba0c29c7e230e7caac3/crates/turborepo-lib/src/task_hash.rs#L439 -const DEFAULT_PASSTHROUGH_ENVS: &[&str] = &[ - // System and shell - "HOME", - "USER", - "TZ", - "LANG", - "SHELL", - "PWD", - "PATH", - // CI/CD environments - "CI", - // Node.js specific - "NODE_OPTIONS", - "COREPACK_HOME", - "NPM_CONFIG_STORE_DIR", - "PNPM_HOME", - // Library paths - "LD_LIBRARY_PATH", - "DYLD_FALLBACK_LIBRARY_PATH", - "LIBPATH", - // Terminal/display - "COLORTERM", - "TERM", - "TERM_PROGRAM", - "DISPLAY", - "FORCE_COLOR", - "NO_COLOR", - // Temporary directories - "TMP", - "TEMP", - // Vercel specific - "VERCEL", - "VERCEL_*", - "NEXT_*", - "USE_OUTPUT_FOR_EDGE_FUNCTIONS", - "NOW_BUILDER", - // Windows specific - "APPDATA", - "PROGRAMDATA", - "SYSTEMROOT", - "SYSTEMDRIVE", - "USERPROFILE", - "HOMEDRIVE", - "HOMEPATH", - // IDE specific (exact matches) - "ELECTRON_RUN_AS_NODE", - "JB_INTERPRETER", - "_JETBRAINS_TEST_RUNNER_RUN_SCOPE_TYPE", - "JB_IDE_*", - // VSCode specific - "VSCODE_*", - // Docker specific - "DOCKER_*", - "BUILDKIT_*", - "COMPOSE_*", - // Token patterns - "*_TOKEN", - // oxc specific - "OXLINT_*", -]; - -const SENSITIVE_PATTERNS: &[&str] = &[ - "*_KEY", - "*_SECRET", - "*_TOKEN", - "*_PASSWORD", - "*_PASS", - "*_PWD", - "*_CREDENTIAL*", - "*_API_KEY", - "*_PRIVATE_*", - "AWS_*", - "GITHUB_*", - "NPM_*TOKEN", - "DATABASE_URL", - "MONGODB_URI", - "REDIS_URL", - "*_CERT*", - // Exact matches for known sensitive names - "PASSWORD", - "SECRET", - "TOKEN", -]; - -impl TaskEnvs { - pub fn resolve( - current_envs: impl Iterator, - base_dir: &AbsolutePath, - task: &ResolvedTaskConfig, - ) -> Result { - // All envs that are passed to the task - let all_patterns: Vec<&str> = DEFAULT_PASSTHROUGH_ENVS - .iter() - .copied() - .chain(task.config.pass_through_envs.iter().map(std::convert::AsRef::as_ref)) - .chain(task.config.envs.iter().map(std::convert::AsRef::as_ref)) - .collect(); - let mut all_envs = resolve_envs_with_patterns(current_envs, &all_patterns)?; - - // envs need to calculate fingerprint - let mut envs_without_pass_through = HashMap::::new(); - if !task.config.envs.is_empty() { - let envs_without_pass_through_patterns = - GlobPatternSet::new(task.config.envs.iter().filter(|s| !s.starts_with('!')))?; - let sensitive_patterns = GlobPatternSet::new(SENSITIVE_PATTERNS)?; - for (name, value) in &all_envs { - if !envs_without_pass_through_patterns.is_match(name) { - continue; - } - let Some(value) = value.to_str() else { - return Err(Error::EnvValueIsNotValidUnicode { - key: name.clone(), - value: value.to_os_string(), - }); - }; - let value: Str = if sensitive_patterns.is_match(name) { - let mut hasher = Sha256::new(); - hasher.update(value.as_bytes()); - format!("sha256:{:x}", hasher.finalize()).into() - } else { - value.into() - }; - envs_without_pass_through.insert(name.clone(), value); - } - } - - // Automatically add FORCE_COLOR environment variable if not already set - // This enables color output in subprocesses when color is supported - // TODO: will remove this temporarily until we have a better solution - if !all_envs.contains_key("FORCE_COLOR") - && !all_envs.contains_key("NO_COLOR") - && let Some(support) = on(Stream::Stdout) - { - let force_color_value = if support.has_16m { - "3" // True color (16 million colors) - } else if support.has_256 { - "2" // 256 colors - } else if support.has_basic { - "1" // Basic ANSI colors - } else { - "0" // No color support - }; - all_envs - .insert("FORCE_COLOR".into(), Arc::::from(OsStr::new(force_color_value))); - } - - // Add VITE_TASK_EXECUTION_ENV to indicate we're running inside vite_task - // This prevents nested auto-install execution - all_envs.insert("VITE_TASK_EXECUTION_ENV".into(), Arc::::from(OsStr::new("1"))); - - // Add node_modules/.bin to PATH - // On Windows, environment variable names are case-insensitive (e.g., "PATH", "Path", "path" are all the same) - // However, Rust's HashMap keys are case-sensitive, so we need to find the existing PATH variable - // regardless of its casing to avoid creating duplicate PATH entries with different casings. - // For example, if the system has "Path", we should use that instead of creating a new "PATH" entry. - let env_path = { - if cfg!(windows) - && let Some(existing_path) = all_envs.iter_mut().find_map(|(name, value)| { - if name.eq_ignore_ascii_case("path") { Some(value) } else { None } - }) - { - // Found existing PATH variable (with any casing), use it - existing_path - } else { - // On Unix or no existing PATH on Windows, create/get "PATH" entry - all_envs.entry("PATH".into()).or_insert_with(|| Arc::::from(OsStr::new(""))) - } - }; - let paths = split_paths(env_path).filter(|path| !path.as_os_str().is_empty()); - - const NODE_MODULES_DOT_BIN: &str = - if cfg!(windows) { "node_modules\\.bin" } else { "node_modules/.bin" }; - - let node_modules_bin_paths = [ - base_dir.join(&task.config.cwd).join(NODE_MODULES_DOT_BIN).into_path_buf(), - base_dir.join(&task.config_dir).join(NODE_MODULES_DOT_BIN).into_path_buf(), - ]; - *env_path = join_paths(node_modules_bin_paths.into_iter().chain(paths))?.into(); - - Ok(Self { all_envs, envs_without_pass_through }) - } -} - -pub static CURRENT_EXECUTION_ID: LazyLock> = - LazyLock::new(|| std::env::var("VITE_TASK_EXECUTION_ID").ok()); - -pub static EXECUTION_SUMMARY_DIR: LazyLock = LazyLock::new(|| { - std::env::var("VITE_TASK_EXECUTION_DIR") - .map_or_else(|_| tempfile::tempdir().unwrap().keep(), PathBuf::from) -}); - -pub async fn execute_task( - execution_id: &str, - resolved_command: &ResolvedTaskCommand, - base_dir: &AbsolutePath, -) -> Result { - let mut cmd = match &resolved_command.fingerprint.command { - TaskCommand::ShellScript(script) => { - let mut cmd = if cfg!(windows) { - let mut cmd = fspy::Command::new("cmd.exe"); - // https://github.com/nodejs/node/blob/dbd24b165128affb7468ca42f69edaf7e0d85a9a/lib/child_process.js#L633 - cmd.args(["/d", "/s", "/c"]); - cmd - } else { - let mut cmd = fspy::Command::new("sh"); - cmd.args(["-c"]); - cmd - }; - cmd.arg(script); - cmd.envs(&resolved_command.all_envs); - cmd - } - TaskCommand::Parsed(task_parsed_command) => { - // handle shell built-ins - match task_parsed_command.program.as_str() { - "echo" => { - let mut prints_new_line = true; - let mut args = task_parsed_command.args.as_slice(); - if let Some(first_arg) = args.first() - && first_arg == "-n" - { - prints_new_line = false; - args = &args[1..]; - } - let mut output = args.iter().map(|arg| arg.as_str()).join(" "); - if prints_new_line { - output.push('\n'); - } - print!("{output}"); - return Ok(ExecutedTask { - std_outputs: vec![StdOutput { - kind: OutputKind::StdOut, - content: Vec::::from(output).into(), - }] - .into(), - exit_status: ExitStatus::default(), - path_reads: Default::default(), - path_writes: Default::default(), - duration: Duration::ZERO, - }); - } - _ => {} - } - if resolved_command.fingerprint.command.need_skip_cache() { - let mut child = tokio::process::Command::new(&task_parsed_command.program) - .args(&task_parsed_command.args) - .envs(&resolved_command.all_envs) - .envs(&task_parsed_command.envs) - .env( - "VITE_OUTER_COMMAND", - if resolved_command.fingerprint.command.has_inner_runner() { - resolved_command.fingerprint.command.to_string() - } else { - String::new() - }, - ) - .env("VITE_TASK_EXECUTION_ID", execution_id) - .env("VITE_TASK_EXECUTION_DIR", EXECUTION_SUMMARY_DIR.as_os_str()) - .current_dir(base_dir.join(&resolved_command.fingerprint.cwd)) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; - let child_stdout = child.stdout.take().unwrap(); - let child_stderr = child.stderr.take().unwrap(); - - let outputs = Mutex::new(Vec::::new()); - - let ((), (), (exit_status, duration)) = try_join3( - collect_std_outputs(&outputs, child_stdout, OutputKind::StdOut), - collect_std_outputs(&outputs, child_stderr, OutputKind::StdErr), - async move { - let start = Instant::now(); - let exit_status = child.wait().await?; - Ok((exit_status, start.elapsed())) - }, - ) - .await?; - - return Ok(ExecutedTask { - std_outputs: outputs.into_inner().unwrap().into(), - exit_status, - path_reads: HashMap::new(), - path_writes: HashMap::new(), - duration, - }); - } - let mut cmd = fspy::Command::new(&task_parsed_command.program); - cmd.args(&task_parsed_command.args); - cmd.envs(&resolved_command.all_envs); - cmd.envs(&task_parsed_command.envs); - cmd - } - }; - - cmd.current_dir(base_dir.join(&resolved_command.fingerprint.cwd)) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - let mut child = cmd.spawn().await?; - - let child_stdout = child.stdout.take().unwrap(); - let child_stderr = child.stderr.take().unwrap(); - - let outputs = Mutex::new(Vec::::new()); - - let ((), (), (termination, duration)) = try_join3( - collect_std_outputs(&outputs, child_stdout, OutputKind::StdOut), - collect_std_outputs(&outputs, child_stderr, OutputKind::StdErr), - async move { - let start = Instant::now(); - let exit_status = child.wait_handle.await?; - Ok((exit_status, start.elapsed())) - }, - ) - .await?; - - let mut path_reads = HashMap::::new(); - let mut path_writes = HashMap::::new(); - for access in termination.path_accesses.iter() { - let relative_path = access - .path - .strip_path_prefix(base_dir, |strip_result| { - let Ok(stripped_path) = strip_result else { - return None; - }; - Some(RelativePathBuf::new(stripped_path).map_err(|err| { - Error::InvalidRelativePath { path: stripped_path.into(), reason: err } - })) - }) - .transpose()?; - - let Some(relative_path) = relative_path else { - // ignore accesses outside the workspace - continue; - }; - if relative_path.as_path().strip_prefix(".git").is_ok() { - // temp workaround for oxlint reading inside .git - continue; - } - if access.mode.contains(AccessMode::READ) { - path_reads.entry(relative_path.clone()).or_insert(PathRead { read_dir_entries: false }); - } - if access.mode.contains(AccessMode::WRITE) { - path_writes.insert(relative_path.clone(), PathWrite); - } - if access.mode.contains(AccessMode::READ_DIR) { - match path_reads.entry(relative_path) { - Entry::Occupied(mut occupied) => occupied.get_mut().read_dir_entries = true, - Entry::Vacant(vacant) => { - vacant.insert(PathRead { read_dir_entries: true }); - } - } - } - } - - let outputs = outputs.into_inner().unwrap(); - tracing::debug!( - "executed task finished, path_reads: {}, path_writes: {}, outputs: {}, exit_status: {}", - path_reads.len(), - path_writes.len(), - outputs.len(), - termination.status, - ); - - // let input_paths = gather_inputs(task, base_dir)?; - - Ok(ExecutedTask { - std_outputs: outputs.into(), - exit_status: termination.status, - path_reads, - path_writes, - duration, - }) -} - -#[expect(dead_code)] -fn gather_inputs( - task: &ResolvedTask, - base_dir: &AbsolutePath, -) -> Result>, Error> { - // Task inferring to be implemented here - let inputs = &task.resolved_config.config.inputs; - if inputs.is_empty() { - return Ok(HashSet::new()); - } - let glob = format!("{{{}}}", itertools::Itertools::join(&mut inputs.iter(), ",")); // TODO: handle "," inside globs - let glob = Glob::new(&glob)?; - - let mut paths: HashSet> = HashSet::new(); - for entry in glob.walk(base_dir.join(&task.resolved_config.config_dir)) { - let entry = entry?; - paths.insert(entry.into_path().into_os_string().into()); - } - Ok(paths) -} - -#[cfg(test)] -mod tests { - use vite_path::relative::RelativePathBuf; - - use super::*; - - #[test] - fn test_force_color_auto_detection() { - use crate::{ - collections::HashSet, - config::{ResolvedTaskConfig, TaskCommand, TaskConfig}, - }; - - let task_config = TaskConfig { - command: TaskCommand::ShellScript("echo test".into()), - cwd: RelativePathBuf::default(), - cacheable: true, - inputs: HashSet::new(), - envs: HashSet::new(), - pass_through_envs: HashSet::new(), - fingerprint_ignores: None, - }; - - let resolved_task_config = - ResolvedTaskConfig { config_dir: RelativePathBuf::default(), config: task_config }; - - let base_dir = if cfg!(windows) { - AbsolutePath::new("C:\\workspace").unwrap() - } else { - AbsolutePath::new("/workspace").unwrap() - }; - - // Test when FORCE_COLOR is not already set - let mock_envs = vec![("PATH".into(), "/usr/bin".into())]; - let result = - TaskEnvs::resolve(mock_envs.into_iter(), base_dir, &resolved_task_config).unwrap(); - - // FORCE_COLOR should be automatically added if color is supported - // Note: This test might vary based on the test environment - let force_color_present = result.all_envs.contains_key("FORCE_COLOR"); - if force_color_present { - let force_color_value = result.all_envs.get("FORCE_COLOR").unwrap(); - let force_color_str = force_color_value.to_str().unwrap(); - // Should be a valid FORCE_COLOR level - assert!(matches!(force_color_str, "0" | "1" | "2" | "3")); - } - - // Test when FORCE_COLOR is already set - should not be overridden - let mock_envs = - vec![("PATH".into(), "/usr/bin".into()), ("FORCE_COLOR".into(), "2".into())]; - let result2 = - TaskEnvs::resolve(mock_envs.into_iter(), base_dir, &resolved_task_config).unwrap(); - - // Should contain the original FORCE_COLOR value - assert!(result2.all_envs.contains_key("FORCE_COLOR")); - let force_color_value = result2.all_envs.get("FORCE_COLOR").unwrap(); - assert_eq!(force_color_value.to_str().unwrap(), "2"); - - // FORCE_COLOR should not be in envs_without_pass_through since it's a passthrough env - assert!(!result2.envs_without_pass_through.contains_key("FORCE_COLOR")); - - // Test when NO_COLOR is already set - FORCE_COLOR should not be automatically added - let mock_envs = vec![("PATH".into(), "/usr/bin".into()), ("NO_COLOR".into(), "1".into())]; - let result3 = - TaskEnvs::resolve(mock_envs.into_iter(), base_dir, &resolved_task_config).unwrap(); - assert!(result3.all_envs.contains_key("NO_COLOR")); - let no_color_value = result3.all_envs.get("NO_COLOR").unwrap(); - assert_eq!(no_color_value.to_str().unwrap(), "1"); - // FORCE_COLOR should not be automatically added since NO_COLOR is set - assert!(!result3.all_envs.contains_key("FORCE_COLOR")); - } - - #[test] - #[cfg(unix)] - fn test_task_envs_stable_ordering() { - use crate::{ - collections::HashSet, - config::{ResolvedTaskConfig, TaskCommand, TaskConfig}, - }; - - // Create a task config with multiple envs in a HashSet - let mut envs = HashSet::new(); - envs.insert("ZEBRA_VAR".into()); - envs.insert("ALPHA_VAR".into()); - envs.insert("MIDDLE_VAR".into()); - envs.insert("BETA_VAR".into()); - envs.insert("NOT_EXISTS_VAR".into()); - envs.insert("APP?_*".into()); - // will auto ignore ! prefix - envs.insert("!APP*".into()); - - let task_config = TaskConfig { - command: TaskCommand::ShellScript("echo test".into()), - cwd: RelativePathBuf::default(), - cacheable: true, - inputs: HashSet::new(), - envs, - pass_through_envs: HashSet::new(), - fingerprint_ignores: None, - }; - - let resolved_task_config = - ResolvedTaskConfig { config_dir: RelativePathBuf::default(), config: task_config }; - - let base_dir = AbsolutePath::new("/workspace").unwrap(); - - // Create mock environment variables - let mock_envs = vec![ - ("ZEBRA_VAR".into(), "zebra_value".into()), - ("ALPHA_VAR".into(), "alpha_value".into()), - ("MIDDLE_VAR".into(), "middle_value".into()), - ("BETA_VAR".into(), "beta_value".into()), - ("VSCODE_VAR".into(), "vscode_value".into()), - ("APP1_TOKEN".into(), "app1_token".into()), - ("APP2_TOKEN".into(), "app2_token".into()), - ("APP1_NAME".into(), "app1_value".into()), - ("APP2_NAME".into(), "app2_value".into()), - ("APP1_PASSWORD".into(), "app1_password".into()), - ("OXLINT_TSGOLINT_PATH".into(), "/path/to/oxlint_tsgolint".into()), - ("PATH".into(), "/usr/bin".into()), - ("HOME".into(), "/home/user".into()), - ]; - - // Resolve envs multiple times - let result1 = - TaskEnvs::resolve(mock_envs.clone().into_iter(), base_dir, &resolved_task_config) - .unwrap(); - let result2 = - TaskEnvs::resolve(mock_envs.clone().into_iter(), base_dir, &resolved_task_config) - .unwrap(); - let result3 = - TaskEnvs::resolve(mock_envs.clone().into_iter(), base_dir, &resolved_task_config) - .unwrap(); - - // Convert to sorted vecs for comparison - let mut envs1: Vec<_> = result1.envs_without_pass_through.iter().collect(); - let mut envs2: Vec<_> = result2.envs_without_pass_through.iter().collect(); - let mut envs3: Vec<_> = result3.envs_without_pass_through.iter().collect(); - - envs1.sort(); - envs2.sort(); - envs3.sort(); - - // Verify all resolutions produce the same result - assert_eq!(envs1, envs2); - assert_eq!(envs2, envs3); - - // Verify all expected variables are present - assert_eq!(envs1.len(), 9); - assert!(envs1.iter().any(|(k, _)| k.as_str() == "ALPHA_VAR")); - assert!(envs1.iter().any(|(k, _)| k.as_str() == "BETA_VAR")); - assert!(envs1.iter().any(|(k, _)| k.as_str() == "MIDDLE_VAR")); - assert!(envs1.iter().any(|(k, _)| k.as_str() == "ZEBRA_VAR")); - assert!(envs1.iter().any(|(k, _)| k.as_str() == "APP1_NAME")); - assert!(envs1.iter().any(|(k, _)| k.as_str() == "APP2_NAME")); - assert!(envs1.iter().any(|(k, _)| k.as_str() == "APP1_PASSWORD")); - assert!(envs1.iter().any(|(k, _)| k.as_str() == "APP1_TOKEN")); - assert!(envs1.iter().any(|(k, _)| k.as_str() == "APP2_TOKEN")); - - // APP1_PASSWORD should be hashed - let password = result1.envs_without_pass_through.get("APP1_PASSWORD").unwrap(); - assert_eq!( - password, - "sha256:17f1ef795d5663faa129f6fe3e5335e67ac7a701d1a70533a5f4b1635413a1aa" - ); - - // Verify default pass-through envs are present - let all_envs = result1.all_envs; - assert!(all_envs.contains_key("VSCODE_VAR")); - assert!(all_envs.contains_key("PATH")); - assert!(all_envs.contains_key("HOME")); - assert!(all_envs.contains_key("APP1_NAME")); - assert!(all_envs.contains_key("APP2_NAME")); - assert!(all_envs.contains_key("APP1_PASSWORD")); - assert!(all_envs.contains_key("APP1_TOKEN")); - assert!(all_envs.contains_key("APP2_TOKEN")); - assert!(all_envs.contains_key("OXLINT_TSGOLINT_PATH")); - - // VITE_TASK_EXECUTION_ENV should always be added automatically - assert!(all_envs.contains_key("VITE_TASK_EXECUTION_ENV")); - let env_value = all_envs.get("VITE_TASK_EXECUTION_ENV").unwrap(); - assert_eq!(env_value.to_str().unwrap(), "1"); - // VITE_TASK_EXECUTION_ENV should not be in envs_without_pass_through since it's not declared - assert!(!result1.envs_without_pass_through.contains_key("VITE_TASK_EXECUTION_ENV")); - } - - #[test] - #[cfg(unix)] - fn test_unix_env_case_sensitive() { - use crate::{ - collections::HashSet, - config::{ResolvedTaskConfig, TaskCommand, TaskConfig}, - }; - - // Test that Unix environment variable matching is case-sensitive - // Unix env vars are case-sensitive, so PATH and path are different - - // Create a task config with envs in different cases - let mut envs = HashSet::new(); - envs.insert("TEST_VAR".into()); - envs.insert("test_var".into()); // Different variable on Unix - envs.insert("Test_Var".into()); // Different variable on Unix - - let task_config = TaskConfig { - command: TaskCommand::ShellScript("echo test".into()), - cwd: RelativePathBuf::default(), - cacheable: true, - inputs: HashSet::new(), - envs, - pass_through_envs: HashSet::new(), - fingerprint_ignores: None, - }; - - let resolved_task_config = - ResolvedTaskConfig { config_dir: RelativePathBuf::default(), config: task_config }; - - // Create mock environment variables with different cases - let mock_envs = vec![ - ("TEST_VAR".into(), "uppercase".into()), - ("test_var".into(), "lowercase".into()), - ("Test_Var".into(), "mixed".into()), - ]; - - // Resolve envs - let result = TaskEnvs::resolve( - mock_envs.into_iter(), - AbsolutePath::new("/tmp").unwrap(), - &resolved_task_config, - ) - .unwrap(); - let envs_without_pass_through = result.envs_without_pass_through; - - // On Unix, all three should be treated as separate variables - assert_eq!( - envs_without_pass_through.len(), - 3, - "Unix should treat different cases as different variables" - ); - - assert_eq!( - envs_without_pass_through.get("TEST_VAR").map(vite_str::Str::as_str), - Some("uppercase") - ); - assert_eq!( - envs_without_pass_through.get("test_var").map(vite_str::Str::as_str), - Some("lowercase") - ); - assert_eq!( - envs_without_pass_through.get("Test_Var").map(vite_str::Str::as_str), - Some("mixed") - ); - } - - #[test] - #[cfg(windows)] - fn test_windows_env_case_insensitive() { - use crate::{ - collections::HashSet, - config::{ResolvedTaskConfig, TaskCommand, TaskConfig}, - }; - - // Create a task config with multiple envs in a HashSet - let mut envs = HashSet::new(); - envs.insert("ZEBRA_VAR".into()); - envs.insert("ALPHA_VAR".into()); - envs.insert("MIDDLE_VAR".into()); - envs.insert("BETA_VAR".into()); - envs.insert("NOT_EXISTS_VAR".into()); - envs.insert("APP?_*".into()); - - let task_config = TaskConfig { - command: TaskCommand::ShellScript("echo test".into()), - cwd: RelativePathBuf::default(), - cacheable: true, - inputs: HashSet::new(), - envs, - pass_through_envs: HashSet::new(), - fingerprint_ignores: None, - }; - - let resolved_task_config = - ResolvedTaskConfig { config_dir: RelativePathBuf::default(), config: task_config }; - - // Create mock environment variables - let mock_envs = vec![ - ("ZEBRA_VAR".into(), "zebra_value".into()), - ("ALPHA_VAR".into(), "alpha_value".into()), - ("MIDDLE_VAR".into(), "middle_value".into()), - ("BETA_VAR".into(), "beta_value".into()), - ("VSCODE_VAR".into(), "vscode_value".into()), - ("app1_name".into(), "app1_value".into()), - ("app2_name".into(), "app2_value".into()), - ("Path".into(), "C:\\Windows\\System32".into()), - ]; - - // Resolve envs multiple times - let result1 = TaskEnvs::resolve( - mock_envs.clone().into_iter(), - AbsolutePath::new("C:\\tmp").unwrap(), - &resolved_task_config, - ) - .unwrap(); - let result2 = TaskEnvs::resolve( - mock_envs.clone().into_iter(), - AbsolutePath::new("C:\\tmp").unwrap(), - &resolved_task_config, - ) - .unwrap(); - let result3 = TaskEnvs::resolve( - mock_envs.clone().into_iter(), - AbsolutePath::new("C:\\tmp").unwrap(), - &resolved_task_config, - ) - .unwrap(); - - // Convert to sorted vecs for comparison - let mut envs1: Vec<_> = result1.envs_without_pass_through.iter().collect(); - let mut envs2: Vec<_> = result2.envs_without_pass_through.iter().collect(); - let mut envs3: Vec<_> = result3.envs_without_pass_through.iter().collect(); - - envs1.sort(); - envs2.sort(); - envs3.sort(); - - // Verify all resolutions produce the same result - assert_eq!(envs1, envs2); - assert_eq!(envs2, envs3); - - // Verify all expected variables are present - assert_eq!(envs1.len(), 6); - assert!(envs1.iter().any(|(k, _)| k.as_str() == "ALPHA_VAR")); - assert!(envs1.iter().any(|(k, _)| k.as_str() == "BETA_VAR")); - assert!(envs1.iter().any(|(k, _)| k.as_str() == "MIDDLE_VAR")); - assert!(envs1.iter().any(|(k, _)| k.as_str() == "ZEBRA_VAR")); - assert!(envs1.iter().any(|(k, _)| k.as_str() == "app1_name")); - assert!(envs1.iter().any(|(k, _)| k.as_str() == "app1_name")); - - // Verify default pass-through envs are present - let all_envs = result1.all_envs; - assert!(all_envs.contains_key("VSCODE_VAR")); - assert!(all_envs.contains_key("Path") || all_envs.contains_key("PATH")); - assert!(all_envs.contains_key("app1_name")); - assert!(all_envs.contains_key("app2_name")); - } - - #[test] - #[cfg(windows)] - fn test_windows_path_case_insensitive_mixed_case() { - use crate::{ - collections::HashSet, - config::{ResolvedTaskConfig, TaskCommand, TaskConfig}, - }; - - let task_config = TaskConfig { - command: TaskCommand::ShellScript("echo test".into()), - cwd: RelativePathBuf::default(), - cacheable: true, - inputs: HashSet::new(), - envs: HashSet::new(), - pass_through_envs: HashSet::new(), - fingerprint_ignores: None, - }; - let resolved = - ResolvedTaskConfig { config_dir: RelativePathBuf::default(), config: task_config }; - - // Mock environment with mixed case "Path" (common on Windows) - let mock_envs = vec![ - (OsString::from("Path"), OsString::from("C:\\existing\\path")), - (OsString::from("OTHER_VAR"), OsString::from("value")), - ]; - - let base_dir = AbsolutePath::new("C:\\workspace\\packages\\app").unwrap(); - - let result = TaskEnvs::resolve(mock_envs.into_iter(), &base_dir, &resolved).unwrap(); - - let all_envs = result.all_envs; - - // Verify that the original "Path" casing is preserved, not "PATH" - assert!(all_envs.contains_key("Path")); - assert!(!all_envs.contains_key("PATH")); - - // Verify the complete PATH value matches expected - let path_value = all_envs.get("Path").unwrap(); - assert_eq!( - path_value.as_ref(), - OsStr::new( - "C:\\workspace\\packages\\app\\node_modules\\.bin;C:\\workspace\\packages\\app\\node_modules\\.bin;C:\\existing\\path" - ) - ); - - // Verify no duplicate PATH entry was created - let path_like_keys: Vec<_> = - all_envs.keys().filter(|k| k.eq_ignore_ascii_case("path")).collect(); - assert_eq!(path_like_keys.len(), 1); - } - - #[test] - #[cfg(windows)] - fn test_windows_path_case_insensitive_uppercase() { - use crate::{ - collections::HashSet, - config::{ResolvedTaskConfig, TaskCommand, TaskConfig}, - }; - - let task_config = TaskConfig { - command: TaskCommand::ShellScript("echo test".into()), - cwd: RelativePathBuf::default(), - cacheable: true, - inputs: HashSet::new(), - envs: HashSet::new(), - pass_through_envs: HashSet::new(), - fingerprint_ignores: None, - }; - let resolved = - ResolvedTaskConfig { config_dir: RelativePathBuf::default(), config: task_config }; - - // Mock environment with uppercase "PATH" - let mock_envs = vec![ - (OsString::from("PATH"), OsString::from("C:\\existing\\path")), - (OsString::from("OTHER_VAR"), OsString::from("value")), - ]; - - let base_dir = AbsolutePath::new("C:\\workspace\\packages\\app").unwrap(); - - let result = TaskEnvs::resolve(mock_envs.into_iter(), &base_dir, &resolved).unwrap(); - - let all_envs = result.all_envs; - - // Verify the complete PATH value matches expected - let path_value = all_envs.get("PATH").unwrap(); - assert_eq!( - path_value.as_ref(), - OsStr::new( - "C:\\workspace\\packages\\app\\node_modules\\.bin;C:\\workspace\\packages\\app\\node_modules\\.bin;C:\\existing\\path" - ) - ); - } - - #[test] - #[cfg(windows)] - fn test_windows_path_created_when_missing() { - use crate::{ - collections::HashSet, - config::{ResolvedTaskConfig, TaskCommand, TaskConfig}, - }; - - let task_config = TaskConfig { - command: TaskCommand::ShellScript("echo test".into()), - cwd: RelativePathBuf::default(), - cacheable: true, - inputs: HashSet::new(), - envs: HashSet::new(), - pass_through_envs: HashSet::new(), - fingerprint_ignores: None, - }; - let resolved = - ResolvedTaskConfig { config_dir: RelativePathBuf::default(), config: task_config }; - - // Mock environment without any PATH variable - let mock_envs = vec![(OsString::from("OTHER_VAR"), OsString::from("value"))]; - - let base_dir = AbsolutePath::new("C:\\workspace\\packages\\app").unwrap(); - - let result = TaskEnvs::resolve(mock_envs.into_iter(), &base_dir, &resolved).unwrap(); - - let all_envs = result.all_envs; - - // Verify the complete PATH value matches expected (only node_modules/.bin paths, no existing path) - let path_value = all_envs.get("PATH").unwrap(); - assert_eq!( - path_value.as_ref(), - OsStr::new( - "C:\\workspace\\packages\\app\\node_modules\\.bin;C:\\workspace\\packages\\app\\node_modules\\.bin" - ) - ); - } - - #[test] - #[cfg(unix)] - fn test_unix_path_case_sensitive() { - use crate::{ - collections::HashSet, - config::{ResolvedTaskConfig, TaskCommand, TaskConfig}, - }; - - let task_config = TaskConfig { - command: TaskCommand::ShellScript("echo test".into()), - cwd: RelativePathBuf::default(), - cacheable: true, - inputs: HashSet::new(), - envs: HashSet::new(), - pass_through_envs: HashSet::new(), - fingerprint_ignores: None, - }; - let resolved = - ResolvedTaskConfig { config_dir: RelativePathBuf::default(), config: task_config }; - - // Mock environment with "PATH" in uppercase (standard on Unix) - let mock_envs = vec![ - (OsString::from("PATH"), OsString::from("/existing/path")), - (OsString::from("OTHER_VAR"), OsString::from("value")), - ]; - - let base_dir = AbsolutePath::new("/workspace/packages/app").unwrap(); - - let result = TaskEnvs::resolve(mock_envs.into_iter(), &base_dir, &resolved).unwrap(); - - let all_envs = result.all_envs; - - // Verify "PATH" exists and the complete value matches expected - let path_value = all_envs.get("PATH").unwrap(); - assert_eq!( - path_value.as_ref(), - OsStr::new( - "/workspace/packages/app/node_modules/.bin:/workspace/packages/app/node_modules/.bin:/existing/path" - ) - ); - - // Verify that on Unix, the code uses exact "PATH" match (case-sensitive) - // This is a regression test to ensure Windows case-insensitive logic doesn't affect Unix - assert!(!all_envs.contains_key("Path")); - assert!(!all_envs.contains_key("path")); - } -} diff --git a/crates/vite_task/src/fingerprint.rs b/crates/vite_task/src/fingerprint.rs deleted file mode 100644 index cc4b4bb9..00000000 --- a/crates/vite_task/src/fingerprint.rs +++ /dev/null @@ -1,759 +0,0 @@ -use std::{fmt::Display, sync::Arc}; - -use bincode::{Decode, Encode}; -use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; -use serde::{Deserialize, Serialize}; -use vite_glob::GlobPatternSet; -use vite_path::{AbsolutePath, RelativePathBuf}; -use vite_str::Str; - -use crate::{ - Error, - collections::HashMap, - execute::{ExecutedTask, PathRead}, - fs::FileSystem, -}; - -/// Part of a command's fingerprint, collected after it is executed. -#[derive(Encode, Decode, Debug, Serialize)] -pub struct PostRunFingerprint { - // Paths the command tried to read, with content fingerprints - pub inputs: HashMap, -} - -#[derive(Encode, Decode, PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] -pub enum DirEntryKind { - File, - Dir, - Symlink, -} - -#[derive(Encode, Decode, PartialEq, Eq, Debug, Serialize, Deserialize)] -pub enum PathFingerprint { - NotFound, - FileContentHash(u64), - /// Folder(None) means the command opened the folder but did not read its entries, - /// this usually happens when a command opens a folder fd to pass it to `openat` calls, not to get its entries. - Folder(Option>), -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub enum PostRunFingerprintMismatch { - InputContentChanged { path: RelativePathBuf }, -} - -impl Display for PostRunFingerprintMismatch { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::InputContentChanged { path } => { - write!(f, "{path} content changed") - } - } - } -} - -impl PostRunFingerprint { - /// Checks if the cached fingerprint is still valid. Returns why if not. - pub fn validate( - &self, - fs: &impl FileSystem, - base_dir: &AbsolutePath, - ) -> Result, Error> { - let input_mismatch = - self.inputs.par_iter().find_map_any(|(input_relative_path, path_fingerprint)| { - let input_full_path = Arc::::from(base_dir.join(input_relative_path)); - let path_read = PathRead { - read_dir_entries: matches!(path_fingerprint, PathFingerprint::Folder(Some(_))), - }; - let current_path_fingerprint = - match fs.fingerprint_path(&input_full_path, path_read) { - Ok(ok) => ok, - Err(err) => return Some(Err(err)), - }; - if path_fingerprint == ¤t_path_fingerprint { - None - } else { - Some(Ok(PostRunFingerprintMismatch::InputContentChanged { - path: input_relative_path.clone(), - })) - } - }); - input_mismatch.transpose() - } - - /// Creates a new fingerprint after the task has been executed - pub fn create( - executed_task: &ExecutedTask, - fs: &impl FileSystem, - base_dir: &AbsolutePath, - fingerprint_ignores: Option<&[Str]>, - ) -> Result { - // Build ignore matcher from patterns if provided - let ignore_matcher = fingerprint_ignores - .filter(|patterns| !patterns.is_empty()) - .map(GlobPatternSet::new) - .transpose()?; - - let inputs = executed_task - .path_reads - .par_iter() - .filter(|(path, _)| { - // Filter out paths that match ignore patterns - ignore_matcher.as_ref().is_none_or(|matcher| !matcher.is_match(path)) - }) - .flat_map(|(path, path_read)| { - Some((|| { - let path_fingerprint = - fs.fingerprint_path(&base_dir.join(path).into(), *path_read)?; - Ok((path.clone(), path_fingerprint)) - })()) - }) - .collect::, Error>>()?; - - tracing::debug!( - "PostRunFingerprint created, got {} inputs, fingerprint_ignores: {:?}", - inputs.len(), - fingerprint_ignores - ); - Ok(Self { inputs }) - } -} - -#[cfg(test)] -mod tests { - use vite_path::RelativePathBuf; - use vite_shell::TaskParsedCommand; - use vite_str::Str; - - use crate::{ - collections::HashSet, - config::{CommandFingerprint, ResolvedTaskConfig, TaskCommand, TaskConfig}, - }; - - #[test] - fn test_command_fingerprint_stable_with_multiple_envs() { - use bincode::{decode_from_slice, encode_to_vec}; - - // Test that CommandFingerprint with TaskCommand::Parsed maintains stable ordering - let parsed_cmd = TaskParsedCommand { - envs: [ - ("VAR_Z".into(), "value_z".into()), - ("VAR_A".into(), "value_a".into()), - ("VAR_M".into(), "value_m".into()), - ] - .into(), - program: "test".into(), - args: vec!["arg1".into(), "arg2".into()], - }; - - let fingerprint1 = CommandFingerprint { - cwd: RelativePathBuf::default(), - command: TaskCommand::Parsed(parsed_cmd.clone()), - envs_without_pass_through: [ - ("ENV_C".into(), "c".into()), - ("ENV_A".into(), "a".into()), - ("ENV_B".into(), "b".into()), - ] - .into_iter() - .collect(), - pass_through_envs: Default::default(), - fingerprint_ignores: None, - }; - - let fingerprint2 = CommandFingerprint { - cwd: RelativePathBuf::default(), - command: TaskCommand::Parsed(parsed_cmd), - envs_without_pass_through: [ - ("ENV_A".into(), "a".into()), - ("ENV_B".into(), "b".into()), - ("ENV_C".into(), "c".into()), - ] - .into_iter() - .collect(), - pass_through_envs: Default::default(), - fingerprint_ignores: None, - }; - - // Serialize both fingerprints - let config = bincode::config::standard(); - - let bytes1 = encode_to_vec(&fingerprint1, config).unwrap(); - let bytes2 = encode_to_vec(&fingerprint2, config).unwrap(); - - // Since we're using sorted iteration in TaskEnvs::resolve, - // the HashMap content should be the same regardless of insertion order - // and the TaskParsedCommand uses BTreeMap which maintains order - - // Decode and compare - let (decoded1, _): (CommandFingerprint, _) = decode_from_slice(&bytes1, config).unwrap(); - let (decoded2, _): (CommandFingerprint, _) = decode_from_slice(&bytes2, config).unwrap(); - - // The fingerprints should be equal since they contain the same data - assert_eq!(decoded1, decoded2); - } - - #[test] - fn test_fingerprint_stability_across_runs() { - use std::{ - collections::hash_map::DefaultHasher, - hash::{Hash, Hasher}, - }; - - use bincode::encode_to_vec; - - // This test simulates what happens when the same task is fingerprinted - // multiple times across different program runs - - for _ in 0..5 { - let parsed_cmd = TaskParsedCommand { - envs: [ - ("BUILD_ENV".into(), "production".into()), - ("API_VERSION".into(), "v2".into()), - ("CACHE_DIR".into(), "/tmp/cache".into()), - ] - .into(), - program: "build".into(), - args: vec!["--optimize".into()], - }; - - let fingerprint = CommandFingerprint { - cwd: RelativePathBuf::default(), - command: TaskCommand::Parsed(parsed_cmd), - envs_without_pass_through: [ - ("NODE_ENV".into(), "production".into()), - ("DEBUG".into(), "false".into()), - ] - .into_iter() - .collect(), - pass_through_envs: Default::default(), - fingerprint_ignores: None, - }; - - // Serialize the fingerprint - let config = bincode::config::standard(); - let bytes = encode_to_vec(&fingerprint, config).unwrap(); - - // Create a hash of the serialized bytes to verify stability - let mut hasher = DefaultHasher::new(); - bytes.hash(&mut hasher); - let hash = hasher.finish(); - - // In a real scenario, this hash would be used as cache key - // Here we just verify it's consistent - // The hash should always be the same for the same logical content - assert_eq!(hash, hash); // This is trivial but in a loop it ensures consistency - } - } - - #[test] - fn test_task_config_with_sorted_envs() { - use bincode::encode_to_vec; - - // Test that TaskConfig produces stable fingerprints even with HashSet envs - let mut envs = HashSet::new(); - envs.insert("VAR_3".into()); - envs.insert("VAR_1".into()); - envs.insert("VAR_2".into()); - - let config = TaskConfig { - command: TaskCommand::ShellScript("npm run build".into()), - cwd: RelativePathBuf::default(), - cacheable: true, - inputs: HashSet::new(), - envs: envs.clone(), - pass_through_envs: HashSet::new(), - fingerprint_ignores: None, - }; - - // Create resolved config - let resolved = ResolvedTaskConfig { config_dir: RelativePathBuf::default(), config }; - - // Serialize multiple times - let bincode_config = bincode::config::standard(); - - let bytes1 = encode_to_vec(&resolved, bincode_config).unwrap(); - let bytes2 = encode_to_vec(&resolved, bincode_config).unwrap(); - - // Should be identical - assert_eq!(bytes1, bytes2); - } - - #[test] - fn test_parsed_command_env_iteration_order() { - // Verify that iteration order is consistent for BTreeMap - let cmd = TaskParsedCommand { - envs: [ - ("Z_VAR".into(), "z".into()), - ("A_VAR".into(), "a".into()), - ("M_VAR".into(), "m".into()), - ] - .into(), - program: "test".into(), - args: vec![], - }; - - // Collect keys multiple times - let keys1: Vec<_> = cmd.envs.keys().cloned().collect(); - let keys2: Vec<_> = cmd.envs.keys().cloned().collect(); - let keys3: Vec<_> = cmd.envs.keys().cloned().collect(); - - // All should be in the same (sorted) order - assert_eq!(keys1, keys2); - assert_eq!(keys2, keys3); - - // Verify alphabetical order - assert_eq!(keys1, vec![Str::from("A_VAR"), Str::from("M_VAR"), Str::from("Z_VAR"),]); - } - - // Tests for PostRunFingerprint::create with fingerprint_ignores - mod fingerprint_ignores_tests { - use std::{process::ExitStatus, sync::Arc, time::Duration}; - - use vite_path::{AbsolutePath, RelativePathBuf}; - - use super::*; - use crate::{ - collections::HashMap, - execute::{ExecutedTask, PathRead}, - fingerprint::{PathFingerprint, PostRunFingerprint}, - fs::FileSystem, - }; - - // Mock FileSystem for testing - struct MockFileSystem; - - impl FileSystem for MockFileSystem { - fn fingerprint_path( - &self, - _path: &Arc, - _read: PathRead, - ) -> Result { - // Return a simple hash for testing purposes - Ok(PathFingerprint::FileContentHash(12345)) - } - } - - fn create_executed_task(paths: Vec<&str>) -> ExecutedTask { - let path_reads: HashMap = paths - .into_iter() - .map(|p| (RelativePathBuf::new(p).unwrap(), PathRead { read_dir_entries: false })) - .collect(); - - ExecutedTask { - std_outputs: Arc::new([]), - exit_status: ExitStatus::default(), - path_reads, - path_writes: HashMap::new(), - duration: Duration::from_secs(1), - } - } - - #[test] - fn test_postrun_fingerprint_no_ignores() { - // When fingerprint_ignores is None, all paths should be included - let executed_task = create_executed_task(vec![ - "src/index.js", - "node_modules/pkg-a/index.js", - "node_modules/pkg-a/package.json", - "dist/bundle.js", - ]); - - let fs = MockFileSystem; - let base_dir = - AbsolutePath::new(if cfg!(windows) { "C:\\test" } else { "/test" }).unwrap(); - - let fingerprint = - PostRunFingerprint::create(&executed_task, &fs, base_dir, None).unwrap(); - - // All 4 paths should be in the fingerprint - assert_eq!(fingerprint.inputs.len(), 4); - assert!( - fingerprint.inputs.contains_key(&RelativePathBuf::new("src/index.js").unwrap()) - ); - assert!( - fingerprint - .inputs - .contains_key(&RelativePathBuf::new("node_modules/pkg-a/index.js").unwrap()) - ); - assert!( - fingerprint.inputs.contains_key( - &RelativePathBuf::new("node_modules/pkg-a/package.json").unwrap() - ) - ); - assert!( - fingerprint.inputs.contains_key(&RelativePathBuf::new("dist/bundle.js").unwrap()) - ); - } - - #[test] - fn test_postrun_fingerprint_empty_ignores() { - // When fingerprint_ignores is Some(&[]), all paths should be included - let executed_task = - create_executed_task(vec!["src/index.js", "node_modules/pkg-a/index.js"]); - - let fs = MockFileSystem; - let base_dir = - AbsolutePath::new(if cfg!(windows) { "C:\\test" } else { "/test" }).unwrap(); - - let fingerprint = - PostRunFingerprint::create(&executed_task, &fs, base_dir, Some(&[])).unwrap(); - - // All paths should be in the fingerprint (empty ignores = no filtering) - assert_eq!(fingerprint.inputs.len(), 2); - } - - #[test] - fn test_postrun_fingerprint_ignore_node_modules() { - // Test ignoring all node_modules files - let executed_task = create_executed_task(vec![ - "src/index.js", - "node_modules/pkg-a/index.js", - "node_modules/pkg-b/lib.js", - "package.json", - ]); - - let fs = MockFileSystem; - let base_dir = - AbsolutePath::new(if cfg!(windows) { "C:\\test" } else { "/test" }).unwrap(); - - let ignore_patterns = vec![Str::from("node_modules/**/*")]; - let fingerprint = - PostRunFingerprint::create(&executed_task, &fs, base_dir, Some(&ignore_patterns)) - .unwrap(); - - // Only 2 paths should remain (src/index.js and package.json) - assert_eq!(fingerprint.inputs.len(), 2); - assert!( - fingerprint.inputs.contains_key(&RelativePathBuf::new("src/index.js").unwrap()) - ); - assert!( - fingerprint.inputs.contains_key(&RelativePathBuf::new("package.json").unwrap()) - ); - assert!( - !fingerprint - .inputs - .contains_key(&RelativePathBuf::new("node_modules/pkg-a/index.js").unwrap()) - ); - assert!( - !fingerprint - .inputs - .contains_key(&RelativePathBuf::new("node_modules/pkg-b/lib.js").unwrap()) - ); - } - - #[test] - fn test_postrun_fingerprint_negation_pattern() { - // Test ignoring node_modules except package.json files - let executed_task = create_executed_task(vec![ - "src", - "src/index.js", - "node_modules/pkg-a", - "node_modules/pkg-a/index.js", - "node_modules/pkg-a/package.json", - "node_modules/pkg-b/lib.js", - "node_modules/pkg-b", - "node_modules/pkg-b/package.json", - "node_modules/pkg-b/node_modules/pkg-c", - "node_modules/pkg-b/node_modules/pkg-c/lib.js", - "node_modules/pkg-b/node_modules/pkg-c/package.json", - "project1/node_modules/pkg-d", - "project1/node_modules/pkg-d/lib.js", - "project1/node_modules/pkg-d/package.json", - "project1/sub1/node_modules/pkg-e", - "project1/sub1/node_modules/pkg-e/lib.js", - "project1/sub1/node_modules/pkg-e/package.json", - ]); - - let fs = MockFileSystem; - let base_dir = - AbsolutePath::new(if cfg!(windows) { "C:\\test" } else { "/test" }).unwrap(); - - let ignore_patterns = vec![ - Str::from("**/node_modules/**"), - Str::from("!**/node_modules/*"), - Str::from("!**/node_modules/**/package.json"), - ]; - let fingerprint = - PostRunFingerprint::create(&executed_task, &fs, base_dir, Some(&ignore_patterns)) - .unwrap(); - - assert_eq!( - fingerprint.inputs.len(), - 12, - "got {:?}", - fingerprint - .inputs - .keys() - .map(std::string::ToString::to_string) - .collect::>() - ); - assert!( - fingerprint.inputs.contains_key(&RelativePathBuf::new("src/index.js").unwrap()) - ); - assert!( - fingerprint - .inputs - .contains_key(&RelativePathBuf::new("node_modules/pkg-a").unwrap()) - ); - assert!( - fingerprint.inputs.contains_key( - &RelativePathBuf::new("node_modules/pkg-a/package.json").unwrap() - ) - ); - assert!( - fingerprint - .inputs - .contains_key(&RelativePathBuf::new("node_modules/pkg-b").unwrap()) - ); - assert!( - fingerprint.inputs.contains_key( - &RelativePathBuf::new("node_modules/pkg-b/package.json").unwrap() - ) - ); - assert!(fingerprint.inputs.contains_key( - &RelativePathBuf::new("node_modules/pkg-b/node_modules/pkg-c").unwrap() - )); - assert!( - fingerprint.inputs.contains_key( - &RelativePathBuf::new("node_modules/pkg-b/node_modules/pkg-c/package.json") - .unwrap() - ) - ); - assert!(fingerprint.inputs.contains_key( - &RelativePathBuf::new("project1/node_modules/pkg-d/package.json").unwrap() - )); - assert!(fingerprint.inputs.contains_key( - &RelativePathBuf::new("project1/sub1/node_modules/pkg-e/package.json").unwrap() - )); - - assert!( - !fingerprint - .inputs - .contains_key(&RelativePathBuf::new("node_modules/pkg-a/index.js").unwrap()) - ); - assert!( - !fingerprint - .inputs - .contains_key(&RelativePathBuf::new("node_modules/pkg-b/lib.js").unwrap()) - ); - assert!(!fingerprint.inputs.contains_key( - &RelativePathBuf::new("node_modules/pkg-b/node_modules/pkg-c/lib.js").unwrap() - )); - assert!(!fingerprint.inputs.contains_key( - &RelativePathBuf::new("project1/node_modules/pkg-d/lib.js").unwrap() - )); - assert!(!fingerprint.inputs.contains_key( - &RelativePathBuf::new("project1/sub1/node_modules/pkg-e/lib.js").unwrap() - )); - } - - #[test] - fn test_postrun_fingerprint_multiple_ignore_patterns() { - // Test multiple independent ignore patterns - let executed_task = create_executed_task(vec![ - "src/index.js", - "node_modules/pkg-a/index.js", - "dist/bundle.js", - "dist/assets/main.css", - ".next/cache/data.json", - "package.json", - ]); - - let fs = MockFileSystem; - let base_dir = - AbsolutePath::new(if cfg!(windows) { "C:\\test" } else { "/test" }).unwrap(); - - let ignore_patterns = vec![ - Str::from("node_modules/**/*"), - Str::from("dist/**/*"), - Str::from(".next/**/*"), - ]; - let fingerprint = - PostRunFingerprint::create(&executed_task, &fs, base_dir, Some(&ignore_patterns)) - .unwrap(); - - // Only src/index.js and package.json should remain - assert_eq!(fingerprint.inputs.len(), 2); - assert!( - fingerprint.inputs.contains_key(&RelativePathBuf::new("src/index.js").unwrap()) - ); - assert!( - fingerprint.inputs.contains_key(&RelativePathBuf::new("package.json").unwrap()) - ); - } - - #[test] - fn test_postrun_fingerprint_wildcard_patterns() { - // Test wildcard patterns for file types - let executed_task = create_executed_task(vec![ - "src/index.js", - "src/utils.js", - "src/types.ts", - "debug.log", - "error.log", - "README.md", - ]); - - let fs = MockFileSystem; - let base_dir = - AbsolutePath::new(if cfg!(windows) { "C:\\test" } else { "/test" }).unwrap(); - - let ignore_patterns = vec![Str::from("**/*.log")]; - let fingerprint = - PostRunFingerprint::create(&executed_task, &fs, base_dir, Some(&ignore_patterns)) - .unwrap(); - - // Should have 4 files (all except .log files) - assert_eq!(fingerprint.inputs.len(), 4); - assert!(!fingerprint.inputs.contains_key(&RelativePathBuf::new("debug.log").unwrap())); - assert!(!fingerprint.inputs.contains_key(&RelativePathBuf::new("error.log").unwrap())); - assert!( - fingerprint.inputs.contains_key(&RelativePathBuf::new("src/index.js").unwrap()) - ); - assert!(fingerprint.inputs.contains_key(&RelativePathBuf::new("README.md").unwrap())); - } - - #[test] - fn test_postrun_fingerprint_complex_negation() { - // Test complex scenario with multiple negations - let executed_task = create_executed_task(vec![ - "src/index.js", - "dist/bundle.js", - "dist/public/index.html", - "dist/public/assets/logo.png", - "dist/internal/config.json", - ]); - - let fs = MockFileSystem; - let base_dir = - AbsolutePath::new(if cfg!(windows) { "C:\\test" } else { "/test" }).unwrap(); - - let ignore_patterns = vec![Str::from("dist/**/*"), Str::from("!dist/public/**")]; - let fingerprint = - PostRunFingerprint::create(&executed_task, &fs, base_dir, Some(&ignore_patterns)) - .unwrap(); - - // Should have: src/index.js + dist/public/* files = 3 total - assert_eq!(fingerprint.inputs.len(), 3); - assert!( - fingerprint.inputs.contains_key(&RelativePathBuf::new("src/index.js").unwrap()) - ); - assert!( - fingerprint - .inputs - .contains_key(&RelativePathBuf::new("dist/public/index.html").unwrap()) - ); - assert!( - fingerprint - .inputs - .contains_key(&RelativePathBuf::new("dist/public/assets/logo.png").unwrap()) - ); - assert!( - !fingerprint.inputs.contains_key(&RelativePathBuf::new("dist/bundle.js").unwrap()) - ); - assert!( - !fingerprint - .inputs - .contains_key(&RelativePathBuf::new("dist/internal/config.json").unwrap()) - ); - } - - #[test] - fn test_postrun_fingerprint_invalid_pattern() { - // Test that invalid glob patterns return an error - let executed_task = create_executed_task(vec!["src/index.js"]); - - let fs = MockFileSystem; - let base_dir = - AbsolutePath::new(if cfg!(windows) { "C:\\test" } else { "/test" }).unwrap(); - - let ignore_patterns = vec![Str::from("[invalid")]; // Invalid glob syntax - let result = - PostRunFingerprint::create(&executed_task, &fs, base_dir, Some(&ignore_patterns)); - - // Should return an error for invalid pattern - assert!(result.is_err()); - } - } - - #[test] - fn test_command_fingerprint_with_fingerprint_ignores() { - use bincode::encode_to_vec; - - // Test that CommandFingerprint includes fingerprint_ignores - let parsed_cmd = TaskParsedCommand { - envs: [].into(), - program: "pnpm".into(), - args: vec!["install".into()], - }; - - let fingerprint_with_ignores = CommandFingerprint { - cwd: RelativePathBuf::default(), - command: TaskCommand::Parsed(parsed_cmd.clone()), - envs_without_pass_through: Default::default(), - pass_through_envs: Default::default(), - fingerprint_ignores: Some(vec![ - Str::from("node_modules/**/*"), - Str::from("!node_modules/**/package.json"), - ]), - }; - - let fingerprint_without_ignores = CommandFingerprint { - cwd: RelativePathBuf::default(), - command: TaskCommand::Parsed(parsed_cmd), - envs_without_pass_through: Default::default(), - pass_through_envs: Default::default(), - fingerprint_ignores: None, - }; - - // Fingerprints should be different when fingerprint_ignores differ - assert_ne!(fingerprint_with_ignores, fingerprint_without_ignores); - - // Serialize to verify they produce different cache keys - let config = bincode::config::standard(); - - let bytes_with = encode_to_vec(&fingerprint_with_ignores, config).unwrap(); - let bytes_without = encode_to_vec(&fingerprint_without_ignores, config).unwrap(); - - assert_ne!( - bytes_with, bytes_without, - "Different fingerprint_ignores should produce different serialized bytes" - ); - } - - #[test] - fn test_command_fingerprint_ignores_order_matters() { - use bincode::encode_to_vec; - - // Test that the order of fingerprint_ignores patterns matters - let parsed_cmd = - TaskParsedCommand { envs: [].into(), program: "build".into(), args: vec![] }; - - let fingerprint1 = CommandFingerprint { - cwd: RelativePathBuf::default(), - command: TaskCommand::Parsed(parsed_cmd.clone()), - envs_without_pass_through: Default::default(), - pass_through_envs: Default::default(), - fingerprint_ignores: Some(vec![Str::from("dist/**/*"), Str::from("!dist/public/**")]), - }; - - let fingerprint2 = CommandFingerprint { - cwd: RelativePathBuf::default(), - command: TaskCommand::Parsed(parsed_cmd), - envs_without_pass_through: Default::default(), - pass_through_envs: Default::default(), - fingerprint_ignores: Some(vec![Str::from("!dist/public/**"), Str::from("dist/**/*")]), - }; - - // Different order should produce different fingerprints - // (because last-match-wins means different semantics) - assert_ne!(fingerprint1, fingerprint2); - - let config = bincode::config::standard(); - - let bytes1 = encode_to_vec(&fingerprint1, config).unwrap(); - let bytes2 = encode_to_vec(&fingerprint2, config).unwrap(); - - assert_ne!(bytes1, bytes2, "Different pattern order should produce different cache keys"); - } -} diff --git a/crates/vite_task/src/fs.rs b/crates/vite_task/src/fs.rs deleted file mode 100644 index b47ba81a..00000000 --- a/crates/vite_task/src/fs.rs +++ /dev/null @@ -1,321 +0,0 @@ -use std::{ - fs::File, - hash::Hasher as _, - io::{self, BufRead, Read}, - sync::Arc, -}; - -use dashmap::DashMap; -use vite_path::{AbsolutePath, AbsolutePathBuf}; -use vite_str::Str; - -use crate::{ - Error, - collections::HashMap, - execute::PathRead, - fingerprint::{DirEntryKind, PathFingerprint}, -}; -pub trait FileSystem: Sync { - fn fingerprint_path( - &self, - path: &Arc, - read: PathRead, - ) -> Result; -} - -#[derive(Debug, Default)] -pub struct RealFileSystem(()); - -fn hash_content(mut stream: impl Read) -> io::Result { - let mut hasher = twox_hash::XxHash3_64::default(); - let mut buf = [0u8; 8192]; - loop { - let n = stream.read(&mut buf)?; - if n == 0 { - break; - } - hasher.write(&buf[..n]); - } - Ok(hasher.finish()) -} - -impl FileSystem for RealFileSystem { - #[tracing::instrument(level = "trace")] - fn fingerprint_path( - &self, - path: &Arc, - path_read: PathRead, - ) -> Result { - let std_path = path.as_path(); - - let file = match File::open(std_path) { - Ok(file) => file, - #[allow(unused)] - Err(err) => { - // On Windows, File::open fails specifically for directories with PermissionDenied - #[cfg(windows)] - { - if err.kind() == io::ErrorKind::PermissionDenied { - // This might be a directory - try reading it as such - return RealFileSystem::process_directory(std_path, path_read); - } - } - if err.kind() != io::ErrorKind::NotFound { - tracing::trace!( - "Uncommon error when opening {:?} for fingerprinting: {}", - std_path, - err - ); - } - // There are many reasons why opening a file might fail (NotFound, InvalidFilename, NotADirectory, PermissionDenied). - // Consider all of them as NotFound for fingerprinting purposes. - return Ok(PathFingerprint::NotFound); - } - }; - - let mut reader = io::BufReader::new(file); - if let Err(io_err) = reader.fill_buf() { - if io_err.kind() != io::ErrorKind::IsADirectory { - return Err(io_err.into()); - } - // Is a directory on Unix - use the optimized nix implementation first - #[cfg(unix)] - { - return Self::process_directory_unix(reader.into_inner(), path_read); - } - #[cfg(windows)] - { - // This shouldn't happen on Windows since File::open should have failed - // But if it does, fallback to std::fs::read_dir - return RealFileSystem::process_directory(std_path, path_read); - } - } - Ok(PathFingerprint::FileContentHash(hash_content(reader)?)) - } -} - -fn should_ignore_entry(name: &[u8]) -> bool { - matches!(name, b"." | b".." | b".DS_Store") || name.eq_ignore_ascii_case(b"dist") -} - -impl RealFileSystem { - #[cfg(unix)] - fn process_directory_unix(fd: File, path_read: PathRead) -> Result { - use bstr::ByteSlice; - use nix::dir::{Dir, Type}; - - let dir_entries: Option> = if path_read.read_dir_entries { - let mut dir_entries = HashMap::::new(); - let dir = Dir::from_fd(fd.into())?; - for entry in dir { - let entry = entry?; - - let entry_kind = match entry.file_type() { - None => todo!("handle DT_UNKNOWN (see readdir(3))"), - Some(Type::File) => DirEntryKind::File, - Some(Type::Directory) => DirEntryKind::Dir, - Some(Type::Symlink) => DirEntryKind::Symlink, - Some(other_type) => { - return Err(Error::UnsupportedFileType(other_type)); - } - }; - let filename: &[u8] = entry.file_name().to_bytes(); - if should_ignore_entry(filename) { - continue; - } - dir_entries.insert(filename.to_str()?.into(), entry_kind); - } - Some(dir_entries) - } else { - None - }; - Ok(PathFingerprint::Folder(dir_entries)) - } - - #[cfg(windows)] - fn process_directory( - path: &std::path::Path, - path_read: PathRead, - ) -> Result { - let dir_entries: Option> = if path_read.read_dir_entries { - let mut dir_entries = HashMap::::new(); - let dir_iter = std::fs::read_dir(path)?; - - for entry in dir_iter { - let entry = entry?; - let file_name = entry.file_name(); - - // Skip special entries (same as Unix version) - if should_ignore_entry(file_name.as_encoded_bytes()) { - continue; - } - - // Get file type with minimal additional syscalls - let entry_kind = match entry.file_type() { - Ok(file_type) => { - if file_type.is_file() { - DirEntryKind::File - } else if file_type.is_dir() { - DirEntryKind::Dir - } else if file_type.is_symlink() { - DirEntryKind::Symlink - } else { - // Use Error::UnsupportedFileType instead of IoWithPath - return Err(Error::UnsupportedFileType(file_type)); - } - } - Err(err) => { - // Return the original error instead of complex path handling - return Err(Error::Io(err)); - } - }; - - // Convert filename to Str - return error for invalid UTF-8 - match file_name.to_str() { - Some(filename_str) => { - dir_entries.insert(filename_str.into(), entry_kind); - } - None => { - // Return error instead of complex path handling - return Err(Error::Io(io::Error::new( - io::ErrorKind::InvalidData, - "Invalid UTF-8 in filename", - ))); - } - } - } - Some(dir_entries) - } else { - None - }; - Ok(PathFingerprint::Folder(dir_entries)) - } -} - -#[derive(Debug, Default)] -pub struct CachedFileSystem { - underlying: FS, - cache: DashMap, -} - -impl FileSystem for CachedFileSystem { - fn fingerprint_path( - &self, - path: &Arc, - path_read: PathRead, - ) -> Result { - self.underlying.fingerprint_path(path, path_read) - - // TODO: fingerprint memory cache - - // Ok(match self - // .cache - // .entry(path.clone()) { - // Entry::Occupied(occupied_entry) => { - // match (occupied_entry.get(), path_read.read_dir_entries) { - - // } - // }, - // Entry::Vacant(vacant_entry) => { - // vacant_entry.insert(self.underlying.fingerprint_path(path, path_read)?).value().clone() - // }, - // }) - // Ok(fingerprint.value().clone()) - } -} - -impl CachedFileSystem { - #[expect(dead_code)] - pub fn invalidate_path(&self, path: &AbsolutePath) { - self.cache.remove(&path.to_absolute_path_buf()); - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use tempfile::TempDir; - - use super::*; - use crate::execute::PathRead; - - #[test] - fn test_fingerprint_nonexistent_file() { - let fs = RealFileSystem::default(); - let nonexistent_path = Arc::::from( - AbsolutePathBuf::new(if cfg!(windows) { - "C:\\nonexistent\\path".into() - } else { - "/nonexistent/path".into() - }) - .unwrap(), - ); - let path_read = PathRead { read_dir_entries: false }; - - let result = fs.fingerprint_path(&nonexistent_path, path_read).unwrap(); - assert!(matches!(result, PathFingerprint::NotFound)); - } - - #[test] - fn test_fingerprint_temp_file() { - let fs = RealFileSystem::default(); - let temp_dir = TempDir::new().unwrap(); - let temp_file = temp_dir.path().join("test_file.txt"); - - // Create a test file with known content - std::fs::write(&temp_file, "Hello, World!").unwrap(); - - let file_path = Arc::::from(AbsolutePathBuf::new(temp_file).unwrap()); - let path_read = PathRead { read_dir_entries: false }; - - let result = fs.fingerprint_path(&file_path, path_read).unwrap(); - assert!(matches!(result, PathFingerprint::FileContentHash(_))); - - // Verify that the same file gives the same hash - let result2 = fs.fingerprint_path(&file_path, path_read).unwrap(); - assert_eq!(result, result2); - } - - #[test] - fn test_fingerprint_temp_directory() { - let fs = RealFileSystem::default(); - let temp_dir = TempDir::new().unwrap(); - - // Create some files in the directory - std::fs::write(temp_dir.path().join("file1.txt"), "content1").unwrap(); - std::fs::write(temp_dir.path().join("file2.txt"), "content2").unwrap(); - - let dir_path = - Arc::::from(AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap()); - let path_read = PathRead { read_dir_entries: true }; - - let result = fs.fingerprint_path(&dir_path, path_read).unwrap(); - - match result { - PathFingerprint::Folder(Some(entries)) => { - // Should contain our test files (but not . or .. or .DS_Store) - assert!(entries.contains_key("file1.txt")); - assert!(entries.contains_key("file2.txt")); - assert_eq!(entries.len(), 2); - } - _ => panic!("Expected folder with entries, got: {result:?}"), - } - - // Test without reading entries - let path_read_no_entries = PathRead { read_dir_entries: false }; - let result_no_entries = match fs.fingerprint_path(&dir_path, path_read_no_entries) { - Ok(result) => result, - Err(err) => { - // On Windows CI, temporary directories might have permission issues - // Skip the test if we get a permission denied error - if cfg!(windows) && err.to_string().contains("Access is denied") { - eprintln!("Skipping test due to Windows permission issue: {err}"); - return; - } - panic!("Unexpected error: {err}"); - } - }; - assert!(matches!(result_no_entries, PathFingerprint::Folder(None))); - } -} diff --git a/crates/vite_task/src/lib.rs b/crates/vite_task/src/lib.rs index 88eb2f20..323ac50e 100644 --- a/crates/vite_task/src/lib.rs +++ b/crates/vite_task/src/lib.rs @@ -1,23 +1,10 @@ -mod cache; mod cli; mod collections; -mod config; -mod error; -mod execute; -mod fingerprint; -mod fs; mod maybe_str; -mod schedule; -mod session; -mod types; -mod ui; +pub mod session; -// Public exports for vite-plus-cli to use -pub use cache::TaskCache; +// Public exports for vite_task_bin pub use cli::CLIArgs; -pub use config::{ResolvedTask, Workspace}; -pub use error::Error; -pub use execute::{CURRENT_EXECUTION_ID, EXECUTION_SUMMARY_DIR}; -pub use schedule::{ExecutionPlan, ExecutionStatus, ExecutionSummary}; -pub use session::{Session, SessionCallbacks}; -pub use types::ResolveCommandResult; +pub use session::{LabeledReporter, Reporter, Session, SessionCallbacks, TaskSynthesizer}; +pub use vite_task_graph::loader; +pub use vite_task_plan::plan_request; diff --git a/crates/vite_task/src/schedule.rs b/crates/vite_task/src/schedule.rs deleted file mode 100644 index 61cc6b98..00000000 --- a/crates/vite_task/src/schedule.rs +++ /dev/null @@ -1,249 +0,0 @@ -use std::{process::ExitStatus, sync::Arc, time::Duration}; - -use futures_core::future::BoxFuture; -use futures_util::future::FutureExt as _; -use petgraph::{algo::toposort, stable_graph::StableDiGraph}; -use serde::{Deserialize, Serialize}; -use tokio::io::AsyncWriteExt as _; -use uuid::Uuid; -use vite_path::AbsolutePath; - -use crate::{ - Error, - cache::{CacheMiss, CommandCacheValue, TaskCache}, - config::{DisplayOptions, ResolvedTask, Workspace}, - execute::{OutputKind, execute_task}, - fs::FileSystem, - ui::get_display_command, -}; - -#[derive(Debug)] -pub struct ExecutionPlan { - steps: Vec, - // node_indices: Vec, - // task_graph: Graph, -} - -/// Status of a task before execution -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct PreExecutionStatus { - pub display_command: Option, - pub task: ResolvedTask, - pub cache_status: CacheStatus, - pub display_options: DisplayOptions, -} -#[derive(Debug, Serialize, Deserialize, Clone)] -pub enum CacheStatus { - /// Cache miss with reason. - /// - /// The task will be executed. - CacheMiss(CacheMiss), - /// Cache hit, will replay - CacheHit { - /// Duration of the original execution - original_duration: Duration, - }, -} - -/// Status of a task execution -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ExecutionStatus { - /// For identifying the task with inner runner and associating the inner summary - pub execution_id: String, - pub pre_execution_status: PreExecutionStatus, - /// `Ok` variant means the task was executed (or replayed), no matter the exit status is zero or non-zero. - /// - /// `Err(_)` means the task doesn't have a exit status at all, e.g. skipped due to failed direct or indirect dependency. - /// - /// For example, for three tasks declared as: "false && echo foo && echo bar", - /// their `execution_result` in order would be: - /// - `Ok(ExitStatus(1))` - /// - `Err(SkippedDueToFailedDependency)` - /// - `Err(SkippedDueToFailedDependency)` - pub execution_result: Result, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub enum ExecutionFailure { - /// this task was skipped because one of its dependencies failed - SkippedDueToFailedDependency, - // TODO: UserCancelled when implementing tui/webui -} - -/// Summary of all task executions -#[derive(Debug, Serialize, Deserialize)] -pub struct ExecutionSummary { - pub execution_statuses: Vec, -} - -impl ExecutionPlan { - /// Creates an execution plan from the task dependency graph. - /// - /// # Execution Order - /// - /// ## With `parallel_run` = true (TODO): - /// Tasks will be grouped by dependency level for concurrent execution. - /// Example groups: - /// - Group 1: `[@test/core#build]` (no dependencies) - /// - Group 2: `[@test/utils#build\[0\]]` (depends on Group 1) - /// - Group 3: `[@test/utils#build\[1\], @test/other#build]` (can run in parallel) - #[tracing::instrument(skip(task_graph))] - pub fn plan( - mut task_graph: StableDiGraph, - parallel_run: bool, - ) -> Result { - // To be consistent with the package graph in vite_package_manager and the dependency graph definition in Wikipedia - // https://en.wikipedia.org/wiki/Dependency_graph, we construct the graph with edges from dependents to dependencies - // e.g. A -> B means A depends on B - // - // For execution we need to reverse the edges first before topological sorting, - // so that tasks without dependencies are executed first - task_graph.reverse(); // Run tasks without dependencies first - - // Always use topological sort to ensure the correct order of execution - // or the task dependencies declaration is meaningless - let node_indices = match toposort(&task_graph, None) { - Ok(ok) => ok, - Err(err) => return Err(Error::CycleDependencies(err)), - }; - - // TODO: implement parallel execution grouping - - // Extract tasks from the graph in the determined order - let steps = node_indices.into_iter().map(|id| task_graph.remove_node(id).unwrap()); - Ok(Self { steps: steps.collect() }) - } - - /// Executes the plan sequentially. - /// - /// For each task: - /// 1. Check if cached result exists and is valid - /// 2. If cache hit: replay the cached output - /// 3. If cache miss: execute the task and cache the result - /// - /// Returns: - /// - `Ok(ExecutionSummary)` containing execution status of all tasks (some may fail with non-zero exit code) - /// - `Err(_)` for other errors (network, filesystem, etc.) - #[tracing::instrument(skip(self, workspace))] - pub async fn execute(self, workspace: &Workspace) -> Result { - let mut execution_statuses = Vec::::with_capacity(self.steps.len()); - for step in self.steps { - execution_statuses.push(Self::execute_resolved_task(step, workspace).await?); - } - Ok(ExecutionSummary { execution_statuses }) - } - - async fn execute_resolved_task( - step: ResolvedTask, - workspace: &Workspace, - ) -> anyhow::Result { - tracing::debug!("Executing task {}", step.display_name()); - let display_options = step.display_options; - - let execution_id = Uuid::new_v4().to_string(); - - // Check cache and prepare execution - let (cache_status, execute_or_replay) = get_cached_or_execute( - &execution_id, - step.clone(), - &workspace.task_cache, - &workspace.fs, - &workspace.root_dir, - ) - .await?; - - let has_inner_runner = step.resolved_config.config.command.has_inner_runner(); - let pre_execution_status = PreExecutionStatus { - display_command: get_display_command(display_options, &step), - task: step, - cache_status, - display_options, - }; - - // The inner runner is expected to display the command and the cache status. The outer runner will skip displaying them. - if !has_inner_runner { - print!("{pre_execution_status}"); - } - - // Execute or replay the task - let exit_status = execute_or_replay.await?; - - // FIXME: Print a new line to separate the tasks output, need a better solution - println!(); - Ok(ExecutionStatus { - execution_id, - pre_execution_status, - execution_result: Ok(exit_status.code().unwrap_or(1)), - }) - } -} - -/// Replay the cached task if fingerprint matches. Otherwise execute the task. -/// Returns (cache miss reason, function to replay or execute) -async fn get_cached_or_execute<'a>( - execution_id: &'a str, - task: ResolvedTask, - cache: &'a TaskCache, - fs: &'a impl FileSystem, - base_dir: &'a AbsolutePath, -) -> Result<(CacheStatus, BoxFuture<'a, Result>), Error> { - Ok(match cache.try_hit(&task, fs, base_dir).await? { - Ok(cache_task) => ( - CacheStatus::CacheHit { original_duration: cache_task.duration }, - ({ - async move { - if task.display_options.ignore_replay { - return Ok(ExitStatus::default()); - } - // replay - let std_outputs = Arc::clone(&cache_task.std_outputs); - let mut stdout = tokio::io::stdout(); - let mut stderr = tokio::io::stderr(); - for output_section in std_outputs.as_ref() { - match output_section.kind { - OutputKind::StdOut => { - stdout.write_all(&output_section.content).await?; - // flush stdout to ensure the output is displayed in the correct order - stdout.flush().await?; - } - OutputKind::StdErr => { - stderr.write_all(&output_section.content).await?; - // flush stderr too - stderr.flush().await?; - } - } - } - Ok(ExitStatus::default()) - } - .boxed() - }), - ), - Err(cache_miss) => ( - CacheStatus::CacheMiss(cache_miss), - async move { - let skip_cache = task.resolved_command.fingerprint.command.need_skip_cache(); - let executed_task = - execute_task(execution_id, &task.resolved_command, base_dir).await?; - let exit_status = executed_task.exit_status; - tracing::debug!( - "executed command `{}` finished, duration: {:?}, skip_cache: {}, {}", - task.resolved_command.fingerprint.command, - executed_task.duration, - skip_cache, - exit_status - ); - if !skip_cache && exit_status.success() { - let cached_task = CommandCacheValue::create( - executed_task, - fs, - base_dir, - task.resolved_config.config.fingerprint_ignores.as_deref(), - )?; - cache.update(&task, cached_task).await?; - } - Ok(exit_status) - } - .boxed(), - ), - }) -} diff --git a/crates/vite_task/src/session.rs b/crates/vite_task/src/session.rs deleted file mode 100644 index 207547a5..00000000 --- a/crates/vite_task/src/session.rs +++ /dev/null @@ -1,173 +0,0 @@ -use std::{ffi::OsStr, fmt::Debug, sync::Arc}; - -use clap::Parser; -use vite_path::AbsolutePath; -use vite_str::Str; -use vite_task_graph::{IndexedTaskGraph, TaskGraph, TaskGraphLoadError, loader::UserConfigLoader}; -use vite_task_plan::{ - ExecutionPlan, TaskGraphLoader, TaskPlanErrorKind, - plan_request::{PlanRequest, SyntheticPlanRequest}, -}; -use vite_workspace::{WorkspaceRoot, find_workspace_root}; - -use crate::{CLIArgs, collections::HashMap}; - -#[derive(Debug)] -enum LazyTaskGraph<'a> { - Uninitialized { workspace_root: WorkspaceRoot, config_loader: &'a dyn UserConfigLoader }, - Initialized(IndexedTaskGraph), -} - -#[async_trait::async_trait(?Send)] -impl TaskGraphLoader for LazyTaskGraph<'_> { - async fn load_task_graph( - &mut self, - ) -> Result<&vite_task_graph::IndexedTaskGraph, TaskGraphLoadError> { - Ok(match self { - Self::Uninitialized { workspace_root, config_loader } => { - let graph = IndexedTaskGraph::load(workspace_root, *config_loader).await?; - *self = Self::Initialized(graph); - match self { - Self::Initialized(graph) => &*graph, - _ => unreachable!(), - } - } - Self::Initialized(graph) => &*graph, - }) - } -} - -pub struct SessionCallbacks<'a, CustomSubCommand> { - task_synthesizer: &'a mut (dyn TaskSynthesizer + 'a), - user_config_loader: &'a mut (dyn UserConfigLoader + 'a), -} - -#[async_trait::async_trait(?Send)] -pub trait TaskSynthesizer: Debug { - fn should_synthesize_for_program(&self, program: &str) -> bool; - async fn synthesize_task( - &mut self, - subcommand: CustomSubCommand, - cwd: &Arc, - ) -> anyhow::Result; -} - -#[derive(derive_more::Debug)] -#[debug(bound())] // Avoid requiring CustomSubCommand: Debug -struct PlanRequestParser<'a, CustomSubCommand> { - task_synthesizer: &'a mut (dyn TaskSynthesizer + 'a), -} - -impl PlanRequestParser<'_, CustomSubCommand> { - async fn get_plan_request_from_cli_args( - &mut self, - cli_args: CLIArgs, - cwd: &Arc, - ) -> anyhow::Result { - match cli_args { - CLIArgs::ViteTaskSubCommand(vite_task_subcommand) => { - Ok(vite_task_subcommand.into_plan_request(cwd)?) - } - CLIArgs::Custom(custom_subcommand) => { - let synthetic_plan_request = - self.task_synthesizer.synthesize_task(custom_subcommand, cwd).await?; - Ok(PlanRequest::Synthetic(synthetic_plan_request)) - } - } - } -} - -#[async_trait::async_trait(?Send)] -impl vite_task_plan::PlanRequestParser - for PlanRequestParser<'_, CustomSubCommand> -{ - async fn get_plan_request( - &mut self, - program: &str, - args: &[Str], - cwd: &Arc, - ) -> anyhow::Result> { - if !self.task_synthesizer.should_synthesize_for_program(program) { - return Ok(None); - } - let cli_args = CLIArgs::::try_parse_from( - std::iter::once(program).chain(args.iter().map(Str::as_str)), - )?; - Ok(Some(self.get_plan_request_from_cli_args(cli_args, cwd).await?)) - } -} - -/// Represents a vite task session for planning and executing tasks. A process typically has one session. -/// -/// A session manages task graph loading internally and provides non-consuming methods to plan and/or execute tasks (allows multiple plans/executions per session). -pub struct Session<'a, CustomSubCommand> { - workspace_path: Arc, - /// A session doesn't necessarily load the task graph immediately. - /// The task graph is loaded on-demand and cached for future use. - lazy_task_graph: LazyTaskGraph<'a>, - - envs: HashMap, Arc>, - cwd: Arc, - - plan_request_parser: PlanRequestParser<'a, CustomSubCommand>, -} - -impl<'a, CustomSubCommand> Session<'a, CustomSubCommand> { - /// Initialize a session with real environment variables and cwd - pub fn init(callbacks: SessionCallbacks<'a, CustomSubCommand>) -> anyhow::Result { - let envs = std::env::vars_os() - .map(|(k, v)| (Arc::::from(k.as_os_str()), Arc::::from(v.as_os_str()))) - .collect(); - Self::init_with(envs, vite_path::current_dir()?.into(), callbacks) - } - - /// Initialize a session with custom cwd, environment variables. Useful for testing. - pub fn init_with( - envs: HashMap, Arc>, - cwd: Arc, - callbacks: SessionCallbacks<'a, CustomSubCommand>, - ) -> anyhow::Result { - let (workspace_root, _) = find_workspace_root(&cwd)?; - Ok(Self { - workspace_path: Arc::clone(&workspace_root.path), - lazy_task_graph: LazyTaskGraph::Uninitialized { - workspace_root, - config_loader: callbacks.user_config_loader, - }, - envs, - cwd, - plan_request_parser: PlanRequestParser { task_synthesizer: callbacks.task_synthesizer }, - }) - } - - pub fn task_graph(&self) -> Option<&TaskGraph> { - match &self.lazy_task_graph { - LazyTaskGraph::Initialized(graph) => Some(graph.task_graph()), - _ => None, - } - } -} - -impl<'a, CustomSubCommand: clap::Subcommand> Session<'a, CustomSubCommand> { - pub async fn plan( - &mut self, - cli_args: CLIArgs, - ) -> Result { - let plan_request = self - .plan_request_parser - .get_plan_request_from_cli_args(cli_args, &self.cwd) - .await - .map_err(|error| { - TaskPlanErrorKind::ParsePlanRequestError { error }.with_empty_call_stack() - })?; - ExecutionPlan::plan( - plan_request, - &self.workspace_path, - &self.cwd, - &self.envs, - &mut self.plan_request_parser, - &mut self.lazy_task_graph, - ) - .await - } -} diff --git a/crates/vite_task/src/session/cache/display.rs b/crates/vite_task/src/session/cache/display.rs new file mode 100644 index 00000000..c70edbdd --- /dev/null +++ b/crates/vite_task/src/session/cache/display.rs @@ -0,0 +1,100 @@ +//! Human-readable formatting for cache status +//! +//! This module provides plain text formatting for cache status. +//! Coloring is handled by the reporter to respect NO_COLOR environment variable. + +use super::{CacheMiss, FingerprintMismatch}; +use crate::session::event::{CacheDisabledReason, CacheStatus}; + +/// Format cache status for inline display (during Start event). +/// +/// Returns Some(formatted_string) for Hit and Miss with reason, None otherwise. +/// - Cache Hit: Shows "cache hit" indicator +/// - Cache Miss (NotFound): No inline message (just command) +/// - Cache Miss (with mismatch): Shows "cache miss" with brief reason +/// - Cache Disabled: No inline message +/// +/// Note: Returns plain text without styling. The reporter applies colors. +pub fn format_cache_status_inline(cache_status: &CacheStatus) -> Option { + match cache_status { + CacheStatus::Hit { .. } => { + // Show "cache hit" indicator when replaying from cache + Some("(✓ cache hit, replaying)".to_string()) + } + CacheStatus::Miss(CacheMiss::NotFound) => { + // No inline message for "not found" case - just show command + // This keeps the output clean for first-time executions + None + } + CacheStatus::Miss(CacheMiss::FingerprintMismatch(mismatch)) => { + // Show "cache miss" with brief reason why cache couldn't be used + // Detailed diff is shown in the summary section + let reason = match mismatch { + FingerprintMismatch::SpawnFingerprintMismatch(_previous) => { + // Simplified inline message - detailed diff shown in summary + "command configuration changed" + } + FingerprintMismatch::PostRunFingerprintMismatch(_diff) => { + // Simplified inline message - detailed diff shown in summary + "input files changed" + } + }; + Some(format!("(✗ cache miss: {}, executing)", reason)) + } + CacheStatus::Disabled(_) => { + // No inline message for disabled cache - keeps output clean + None + } + } +} + +/// Format cache status for summary display (post-execution). +/// +/// Returns a formatted string showing detailed cache information. +/// - Cache Hit: Shows saved time +/// - Cache Miss (NotFound): Indicates first-time execution +/// - Cache Miss (with mismatch): Shows specific reason with details +/// - Cache Disabled: Shows user-friendly reason message +/// +/// Note: Returns plain text without styling. The reporter applies colors. +pub fn format_cache_status_summary(cache_status: &CacheStatus) -> String { + match cache_status { + CacheStatus::Hit { replayed_duration } => { + // Show saved time for cache hits + format!("→ Cache hit - output replayed - {replayed_duration:.2?} saved") + } + CacheStatus::Miss(CacheMiss::NotFound) => { + // First time running this task - no previous cache entry + "→ Cache miss: no previous cache entry found".to_string() + } + CacheStatus::Miss(CacheMiss::FingerprintMismatch(mismatch)) => { + // Show specific reason why cache was invalidated + match mismatch { + FingerprintMismatch::SpawnFingerprintMismatch(_previous_fingerprint) => { + // For spawn fingerprint mismatch, we would need the current fingerprint + // to show detailed "from X to Y" diffs. For now, show a generic message. + // TODO: Consider passing current fingerprint to enable detailed diffs + "→ Cache miss: command configuration changed".to_string() + } + FingerprintMismatch::PostRunFingerprintMismatch(diff) => { + // Post-run mismatch has specific path information + use crate::session::execute::fingerprint::PostRunFingerprintMismatch; + match diff { + PostRunFingerprintMismatch::InputContentChanged { path } => { + format!("→ Cache miss: content of input '{path}' changed") + } + } + } + } + } + CacheStatus::Disabled(reason) => { + // Display user-friendly message for each disabled reason + let message = match reason { + CacheDisabledReason::InProcessExecution => "Cache disabled for Built-In Command", + CacheDisabledReason::NoCacheMetadata => "Cache disabled in task configuration", + CacheDisabledReason::CycleDetected => "Cache disabled: cycle detected", + }; + format!("→ {message}") + } + } +} diff --git a/crates/vite_task/src/session/cache/fingerprint.rs b/crates/vite_task/src/session/cache/fingerprint.rs new file mode 100644 index 00000000..c691ce59 --- /dev/null +++ b/crates/vite_task/src/session/cache/fingerprint.rs @@ -0,0 +1,70 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use bincode::{Decode, Encode}; +use diff::Diff; +use serde::{Deserialize, Serialize}; +use vite_path::RelativePathBuf; +use vite_str::Str; + +/// Fingerprint for command execution that affects caching. +/// +/// # Environment Variable Impact on Cache +/// +/// The `envs_without_pass_through` field is crucial for cache correctness: +/// - Only includes envs explicitly declared in the task's `envs` array +/// - Does NOT include pass-through envs (PATH, CI, etc.) +/// - These envs become part of the cache key +/// +/// When a task runs: +/// 1. All envs (including pass-through) are available to the process +/// 2. Only declared envs affect the cache key +/// 3. If a declared env changes value, cache will miss +/// 4. If a pass-through env changes, cache will still hit +/// +/// For built-in tasks (lint, build, etc): +/// - The resolver provides envs which become part of the fingerprint +/// - If resolver provides different envs between runs, cache breaks +/// - Each built-in task type must have unique task name to avoid cache collision +/// +/// # Fingerprint Ignores Impact on Cache +/// +/// The `fingerprint_ignores` field controls which files are tracked in `PostRunFingerprint`: +/// - Changes to this config must invalidate the cache +/// - Vec maintains insertion order (pattern order matters for last-match-wins semantics) +/// - Even though ignore patterns only affect `PostRunFingerprint`, the config itself is part of the cache key +#[derive(Encode, Decode, Debug, Serialize, Deserialize, PartialEq, Eq, Diff, Clone)] +#[diff(attr(#[derive(Debug)]))] +pub struct SpawnFingerprint { + pub cwd: RelativePathBuf, + pub command_fingerprint: CommandFingerprint, + /// Environment variables that affect caching (excludes pass-through envs) + pub fingerprinted_envs: BTreeMap, // using BTreeMap to have a stable order in cache db + + /// even though value changes to `pass_through_envs` shouldn't invalidate the cache, + /// The names should still be fingerprinted so that the cache can be invalidated if the `pass_through_envs` config changes + pub pass_through_envs: BTreeSet, // using BTreeSet to have a stable order in cache db + + /// Glob patterns for fingerprint filtering. Order matters (last match wins). + /// Changes to this config invalidate the cache to ensure correct fingerprint tracking. + pub fingerprint_ignores: Option>, +} + +/// The program fingerprint used in `SpawnFingerprint` +#[derive(Encode, Decode, Debug, Serialize, Deserialize, PartialEq, Eq, Diff, Clone)] +#[diff(attr(#[derive(Debug)]))] +enum ProgramFingerprint { + /// If the program is outside the workspace, fingerprint by its name only (like `node`, `npm`, etc) + OutsideWorkspace { program_name: Str }, + + /// If the program is inside the workspace, fingerprint by its path relative to the workspace root + InsideWorkspace { relative_path: RelativePathBuf }, +} + +#[derive(Encode, Decode, Debug, Serialize, Deserialize, PartialEq, Eq, Diff, Clone)] +#[diff(attr(#[derive(Debug)]))] +enum CommandFingerprint { + /// A program with args to be executed directly + Program { program_fingerprint: ProgramFingerprint, args: Vec }, + /// A script to be executed by os shell (sh or cmd) + ShellScript { script: Str, extra_args: Vec }, +} diff --git a/crates/vite_task/src/session/cache/mod.rs b/crates/vite_task/src/session/cache/mod.rs new file mode 100644 index 00000000..51787e98 --- /dev/null +++ b/crates/vite_task/src/session/cache/mod.rs @@ -0,0 +1,268 @@ +//! Execution cache for storing and retrieving cached command results. + +pub mod display; + +use std::{fmt::Display, io::Write, sync::Arc, time::Duration}; + +use bincode::{Decode, Encode, decode_from_slice, encode_to_vec}; +// Re-export display functions for convenience +pub use display::{format_cache_status_inline, format_cache_status_summary}; +use rusqlite::{Connection, OptionalExtension as _, config::DbConfig}; +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; +use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_task_plan::cache_metadata::{CacheMetadata, ExecutionCacheKey, SpawnFingerprint}; + +use super::execute::{ + fingerprint::{PostRunFingerprint, PostRunFingerprintMismatch}, + spawn::StdOutput, +}; + +/// Command cache value, for validating post-run fingerprint after the spawn fingerprint is matched, +/// and replaying the std outputs if validated. +#[derive(Debug, Encode, Decode, Serialize)] +pub struct CommandCacheValue { + pub post_run_fingerprint: PostRunFingerprint, + pub std_outputs: Arc<[StdOutput]>, + pub duration: Duration, +} + +#[derive(Debug)] +pub struct ExecutionCache { + conn: Mutex, +} + +const BINCODE_CONFIG: bincode::config::Configuration = bincode::config::standard(); + +#[derive(Debug, Serialize, Deserialize)] +pub enum CacheMiss { + NotFound, + FingerprintMismatch(FingerprintMismatch), +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum FingerprintMismatch { + /// Found the cache entry of the same task run, but the spawn fingerprint mismatches + /// this happens when the command itself or an env changes. + SpawnFingerprintMismatch(SpawnFingerprint), + /// Found the cache entry with the same spawn fingerprint, but the post-run fingerprint mismatches + PostRunFingerprintMismatch(PostRunFingerprintMismatch), +} + +impl Display for FingerprintMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::SpawnFingerprintMismatch(old_fingerprint) => { + // TODO: improve the display of spawn fingerprint diff + write!(f, "Spawn fingerprint changed: {old_fingerprint:?}") + } + Self::PostRunFingerprintMismatch(diff) => Display::fmt(diff, f), + } + } +} + +impl ExecutionCache { + pub fn load_from_path(cache_path: AbsolutePathBuf) -> anyhow::Result { + let path: &AbsolutePath = cache_path.as_ref(); + tracing::info!("Creating task cache directory at {:?}", path); + std::fs::create_dir_all(path)?; + + let db_path = path.join("cache.db"); + let conn = Connection::open(db_path.as_path())?; + conn.execute_batch("PRAGMA journal_mode=WAL;")?; + loop { + let user_version: u32 = conn.query_one("PRAGMA user_version", (), |row| row.get(0))?; + match user_version { + 0 => { + // fresh new db + conn.execute( + "CREATE TABLE spawn_fingerprint_cache (key BLOB PRIMARY KEY, value BLOB);", + (), + )?; + conn.execute( + "CREATE TABLE execution_key_to_fingerprint (key BLOB PRIMARY KEY, value BLOB);", + (), + )?; + conn.execute("PRAGMA user_version = 4", ())?; + } + 1..=3 => { + // old internal db version. reset + conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, true)?; + conn.execute("VACUUM", ())?; + conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, false)?; + } + 4 => break, // current version + 5.. => { + return Err(anyhow::anyhow!("Unrecognized database version: {}", user_version)); + } + } + } + Ok(Self { conn: Mutex::new(conn) }) + } + + #[tracing::instrument] + pub async fn save(self) -> anyhow::Result<()> { + // do some cleanup in the future + Ok(()) + } + + /// Try to hit cache with spawn fingerprint. + /// Returns `Ok(Ok(cache_value))` on cache hit, `Ok(Err(cache_miss))` on miss. + pub async fn try_hit( + &self, + cache_metadata: &CacheMetadata, + base_dir: &AbsolutePath, + ) -> anyhow::Result> { + let spawn_fingerprint = &cache_metadata.spawn_fingerprint; + let execution_cache_key = &cache_metadata.execution_cache_key; + + // Try to directly find the cache by spawn fingerprint first + if let Some(cache_value) = self.get_by_spawn_fingerprint(spawn_fingerprint).await? { + // Validate post-run fingerprint + if let Some(post_run_fingerprint_mismatch) = + cache_value.post_run_fingerprint.validate(base_dir)? + { + // Found the cache with the same spawn fingerprint, but the post-run fingerprint mismatches + return Ok(Err(CacheMiss::FingerprintMismatch( + FingerprintMismatch::PostRunFingerprintMismatch(post_run_fingerprint_mismatch), + ))); + } + // Associate the execution key to the spawn fingerprint if not already, + // so that next time we can find it and report spawn fingerprint mismatch + self.upsert_execution_key_to_fingerprint(execution_cache_key, spawn_fingerprint) + .await?; + return Ok(Ok(cache_value)); + } + + // No cache found with the current spawn fingerprint, + // check if execution key maps to different fingerprint + if let Some(old_spawn_fingerprint) = + self.get_fingerprint_by_execution_key(execution_cache_key).await? + { + // Found a spawn fingerprint associated with the same execution key, + // meaning the command or env has changed since last run + return Ok(Err(CacheMiss::FingerprintMismatch( + FingerprintMismatch::SpawnFingerprintMismatch(old_spawn_fingerprint), + ))); + } + + Ok(Err(CacheMiss::NotFound)) + } + + /// Update cache after successful execution. + pub async fn update( + &self, + cache_metadata: &CacheMetadata, + cache_value: CommandCacheValue, + ) -> anyhow::Result<()> { + let spawn_fingerprint = &cache_metadata.spawn_fingerprint; + let execution_cache_key = &cache_metadata.execution_cache_key; + + self.upsert_spawn_fingerprint_cache(spawn_fingerprint, &cache_value).await?; + self.upsert_execution_key_to_fingerprint(execution_cache_key, spawn_fingerprint).await?; + Ok(()) + } +} + +// Basic database operations +impl ExecutionCache { + async fn get_key_by_value>( + &self, + table: &str, + key: &K, + ) -> anyhow::Result> { + let conn = self.conn.lock().await; + let mut select_stmt = + conn.prepare_cached(&format!("SELECT value FROM {table} WHERE key=?"))?; + let key_blob = encode_to_vec(key, BINCODE_CONFIG)?; + let Some(value_blob) = + select_stmt.query_row::, _, _>([key_blob], |row| row.get(0)).optional()? + else { + return Ok(None); + }; + let (value, _) = decode_from_slice::(&value_blob, BINCODE_CONFIG)?; + Ok(Some(value)) + } + + async fn get_by_spawn_fingerprint( + &self, + spawn_fingerprint: &SpawnFingerprint, + ) -> anyhow::Result> { + self.get_key_by_value("spawn_fingerprint_cache", spawn_fingerprint).await + } + + async fn get_fingerprint_by_execution_key( + &self, + execution_cache_key: &ExecutionCacheKey, + ) -> anyhow::Result> { + self.get_key_by_value("execution_key_to_fingerprint", execution_cache_key).await + } + + async fn upsert( + &self, + table: &str, + key: &K, + value: &V, + ) -> anyhow::Result<()> { + let conn = self.conn.lock().await; + let key_blob = encode_to_vec(key, BINCODE_CONFIG)?; + let value_blob = encode_to_vec(value, BINCODE_CONFIG)?; + let mut update_stmt = conn.prepare_cached(&format!( + "INSERT INTO {table} (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value=?2" + ))?; + update_stmt.execute([key_blob, value_blob])?; + Ok(()) + } + + async fn upsert_spawn_fingerprint_cache( + &self, + spawn_fingerprint: &SpawnFingerprint, + cache_value: &CommandCacheValue, + ) -> anyhow::Result<()> { + self.upsert("spawn_fingerprint_cache", spawn_fingerprint, cache_value).await + } + + async fn upsert_execution_key_to_fingerprint( + &self, + execution_cache_key: &ExecutionCacheKey, + spawn_fingerprint: &SpawnFingerprint, + ) -> anyhow::Result<()> { + self.upsert("execution_key_to_fingerprint", execution_cache_key, spawn_fingerprint).await + } + + async fn list_table + Serialize, V: Decode<()> + Serialize>( + &self, + table: &str, + out: &mut impl Write, + ) -> anyhow::Result<()> { + let conn = self.conn.lock().await; + let mut select_stmt = conn.prepare_cached(&format!("SELECT key, value FROM {table}"))?; + let mut rows = select_stmt.query([])?; + while let Some(row) = rows.next()? { + let key_blob: Vec = row.get(0)?; + let value_blob: Vec = row.get(1)?; + let (key, _) = decode_from_slice::(&key_blob, BINCODE_CONFIG)?; + let (value, _) = decode_from_slice::(&value_blob, BINCODE_CONFIG)?; + writeln!( + out, + "{} => {}", + serde_json::to_string_pretty(&key)?, + serde_json::to_string_pretty(&value)? + )?; + } + Ok(()) + } + + pub async fn list(&self, mut out: impl Write) -> anyhow::Result<()> { + out.write_all(b"------- execution_key_to_fingerprint -------\n")?; + self.list_table::( + "execution_key_to_fingerprint", + &mut out, + ) + .await?; + out.write_all(b"------- spawn_fingerprint_cache -------\n")?; + self.list_table::("spawn_fingerprint_cache", &mut out) + .await?; + Ok(()) + } +} diff --git a/crates/vite_task/src/session/event.rs b/crates/vite_task/src/session/event.rs new file mode 100644 index 00000000..b80416e6 --- /dev/null +++ b/crates/vite_task/src/session/event.rs @@ -0,0 +1,65 @@ +use std::time::Duration; + +use bstr::BString; +// Re-export ExecutionItemDisplay from vite_task_plan since it's the canonical definition +pub use vite_task_plan::ExecutionItemDisplay; + +use super::cache::CacheMiss; + +#[derive(Debug)] +pub enum OutputKind { + Stdout, + Stderr, +} + +#[derive(Debug)] +pub enum CacheDisabledReason { + InProcessExecution, + NoCacheMetadata, + CycleDetected, +} + +#[derive(Debug)] +pub enum CacheStatus { + Disabled(CacheDisabledReason), + Miss(CacheMiss), + Hit { replayed_duration: Duration }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ExecutionId(u32); + +impl ExecutionId { + pub(crate) fn zero() -> Self { + Self(0) + } + + pub(crate) fn next(&self) -> Self { + Self(self.0.checked_add(1).expect("ExecutionId overflow")) + } +} + +pub struct ExecutionStartedEvent { + pub execution_id: ExecutionId, + pub display: ExecutionItemDisplay, +} + +pub struct ExecutionOutputEvent { + pub execution_id: ExecutionId, + pub kind: OutputKind, + pub content: BString, +} + +#[derive(Debug)] +pub struct ExecutionEvent { + pub execution_id: ExecutionId, + pub kind: ExecutionEventKind, +} + +#[derive(Debug)] +pub enum ExecutionEventKind { + Start { display: Option, cache_status: CacheStatus }, + Output { kind: OutputKind, content: BString }, + Error { message: String }, + Finish { status: Option }, +} diff --git a/crates/vite_task/src/session/execute/fingerprint.rs b/crates/vite_task/src/session/execute/fingerprint.rs new file mode 100644 index 00000000..c7513c76 --- /dev/null +++ b/crates/vite_task/src/session/execute/fingerprint.rs @@ -0,0 +1,273 @@ +//! Post-run fingerprinting for execution caching. +//! +//! This module provides types and functions for creating and validating +//! fingerprints of file system state after task execution. + +use std::{ + fs::File, + hash::Hasher as _, + io::{self, BufRead, Read}, + sync::Arc, +}; + +use bincode::{Decode, Encode}; +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; +use serde::{Deserialize, Serialize}; +use vite_glob::GlobPatternSet; +use vite_path::{AbsolutePath, RelativePathBuf}; +use vite_str::Str; + +use super::spawn::PathRead; +use crate::collections::HashMap; + +/// Post-run fingerprint capturing file state after execution. +/// Used to validate whether cached outputs are still valid. +#[derive(Encode, Decode, Debug, Serialize)] +pub struct PostRunFingerprint { + /// Paths accessed during execution with their content fingerprints + pub inputs: HashMap, +} + +/// Fingerprint for a single path (file or directory) +#[derive(Encode, Decode, PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] +pub enum PathFingerprint { + /// Path was not found when fingerprinting + NotFound, + /// File content hash using xxHash3_64 + FileContentHash(u64), + /// Directory with optional entry listing. + /// `Folder(None)` means the directory was opened but entries were not read + /// (e.g., for `openat` calls). + /// `Folder(Some(_))` contains the directory entries. + Folder(Option>), +} + +/// Kind of directory entry +#[derive(Encode, Decode, PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] +pub enum DirEntryKind { + File, + Dir, + Symlink, +} + +/// Describes why the post-run fingerprint validation failed +#[derive(Debug, Serialize, Deserialize, Clone)] +pub enum PostRunFingerprintMismatch { + InputContentChanged { path: RelativePathBuf }, +} + +impl std::fmt::Display for PostRunFingerprintMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InputContentChanged { path } => { + write!(f, "{path} content changed") + } + } + } +} + +impl PostRunFingerprint { + /// Creates a new fingerprint from path accesses after task execution. + /// + /// # Arguments + /// * `path_reads` - Map of paths that were read during execution + /// * `base_dir` - Workspace root for resolving relative paths + /// * `fingerprint_ignores` - Optional glob patterns to exclude from fingerprinting + pub fn create( + path_reads: &HashMap, + base_dir: &AbsolutePath, + fingerprint_ignores: Option<&[Str]>, + ) -> anyhow::Result { + // Build ignore matcher from patterns if provided + let ignore_matcher = fingerprint_ignores + .filter(|patterns| !patterns.is_empty()) + .map(GlobPatternSet::new) + .transpose()?; + + let inputs = path_reads + .par_iter() + .filter(|(path, _)| { + // Apply ignore patterns if present + if let Some(ref matcher) = ignore_matcher { + !matcher.is_match(path.as_str()) + } else { + true + } + }) + .map(|(relative_path, path_read)| { + let full_path = Arc::::from(base_dir.join(relative_path)); + let fingerprint = fingerprint_path(&full_path, *path_read)?; + Ok((relative_path.clone(), fingerprint)) + }) + .collect::>>()?; + + Ok(Self { inputs }) + } + + /// Validates the fingerprint against current filesystem state. + /// Returns `Some(mismatch)` if validation fails, `None` if valid. + pub fn validate( + &self, + base_dir: &AbsolutePath, + ) -> anyhow::Result> { + let input_mismatch = + self.inputs.par_iter().find_map_any(|(input_relative_path, path_fingerprint)| { + let input_full_path = Arc::::from(base_dir.join(input_relative_path)); + let path_read = PathRead { + read_dir_entries: matches!(path_fingerprint, PathFingerprint::Folder(Some(_))), + }; + let current_path_fingerprint = match fingerprint_path(&input_full_path, path_read) { + Ok(ok) => ok, + Err(err) => return Some(Err(err)), + }; + if path_fingerprint == ¤t_path_fingerprint { + None + } else { + Some(Ok(PostRunFingerprintMismatch::InputContentChanged { + path: input_relative_path.clone(), + })) + } + }); + input_mismatch.transpose() + } +} + +/// Hash file content using xxHash3_64 +fn hash_content(mut stream: impl Read) -> io::Result { + let mut hasher = twox_hash::XxHash3_64::default(); + let mut buf = [0u8; 8192]; + loop { + let n = stream.read(&mut buf)?; + if n == 0 { + break; + } + hasher.write(&buf[..n]); + } + Ok(hasher.finish()) +} + +/// Check if a directory entry should be ignored in fingerprinting +fn should_ignore_entry(name: &[u8]) -> bool { + matches!(name, b"." | b".." | b".DS_Store") || name.eq_ignore_ascii_case(b"dist") +} + +/// Fingerprint a single path +pub fn fingerprint_path( + path: &Arc, + path_read: PathRead, +) -> anyhow::Result { + let std_path = path.as_path(); + + let file = match File::open(std_path) { + Ok(file) => file, + #[allow(unused)] + Err(err) => { + // On Windows, File::open fails specifically for directories with PermissionDenied + #[cfg(windows)] + { + if err.kind() == io::ErrorKind::PermissionDenied { + // This might be a directory - try reading it as such + return process_directory(std_path, path_read); + } + } + if err.kind() != io::ErrorKind::NotFound { + tracing::trace!( + "Uncommon error when opening {:?} for fingerprinting: {}", + std_path, + err + ); + } + // Treat all open errors as NotFound for fingerprinting purposes + return Ok(PathFingerprint::NotFound); + } + }; + + let mut reader = io::BufReader::new(file); + if let Err(io_err) = reader.fill_buf() { + if io_err.kind() != io::ErrorKind::IsADirectory { + return Err(io_err.into()); + } + // Is a directory on Unix - use the optimized nix implementation + #[cfg(unix)] + { + return process_directory_unix(reader.into_inner(), path_read); + } + #[cfg(windows)] + { + return process_directory(std_path, path_read); + } + } + Ok(PathFingerprint::FileContentHash(hash_content(reader)?)) +} + +/// Process a directory on Windows using std::fs::read_dir +#[cfg(windows)] +fn process_directory( + path: &std::path::Path, + path_read: PathRead, +) -> anyhow::Result { + if !path_read.read_dir_entries { + return Ok(PathFingerprint::Folder(None)); + } + + let mut entries = HashMap::new(); + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let name = entry.file_name(); + let name_bytes = name.as_encoded_bytes(); + + if should_ignore_entry(name_bytes) { + continue; + } + + let file_type = entry.file_type()?; + let kind = if file_type.is_file() { + DirEntryKind::File + } else if file_type.is_dir() { + DirEntryKind::Dir + } else { + DirEntryKind::Symlink + }; + + let name_str = name.to_string_lossy(); + entries.insert(Str::from(name_str.as_ref()), kind); + } + + Ok(PathFingerprint::Folder(Some(entries))) +} + +/// Process a directory on Unix using nix for efficiency +#[cfg(unix)] +fn process_directory_unix(file: File, path_read: PathRead) -> anyhow::Result { + use std::os::fd::AsFd; + + if !path_read.read_dir_entries { + return Ok(PathFingerprint::Folder(None)); + } + + let fd = file.as_fd(); + let mut dir = nix::dir::Dir::from_fd(fd.try_clone_to_owned()?)?; + + let mut entries = HashMap::new(); + for entry in dir.iter() { + let entry = entry?; + let name = entry.file_name().to_bytes(); + + if should_ignore_entry(name) { + continue; + } + + let kind = match entry.file_type() { + Some(nix::dir::Type::File) => DirEntryKind::File, + Some(nix::dir::Type::Directory) => DirEntryKind::Dir, + Some(nix::dir::Type::Symlink) => DirEntryKind::Symlink, + // Treat other types as files for fingerprinting + _ => DirEntryKind::File, + }; + + let name_str = String::from_utf8_lossy(name); + entries.insert(Str::from(name_str.as_ref()), kind); + } + + Ok(PathFingerprint::Folder(Some(entries))) +} diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs new file mode 100644 index 00000000..2b473419 --- /dev/null +++ b/crates/vite_task/src/session/execute/mod.rs @@ -0,0 +1,353 @@ +pub mod fingerprint; +pub mod spawn; + +use std::sync::Arc; + +use futures_util::FutureExt; +use petgraph::{algo::toposort, graph::DiGraph}; +use vite_path::AbsolutePath; +use vite_task_graph::IndexedTaskGraph; +use vite_task_plan::{ + ExecutionItemKind, ExecutionPlan, LeafExecutionKind, SpawnExecution, TaskExecution, + execution_graph::ExecutionIx, +}; + +use self::{ + fingerprint::PostRunFingerprint, + spawn::{OutputKind as SpawnOutputKind, spawn_with_tracking}, +}; +use super::{ + cache::{CommandCacheValue, ExecutionCache}, + event::{ + CacheDisabledReason, CacheStatus, ExecutionEvent, ExecutionEventKind, ExecutionId, + ExecutionItemDisplay, OutputKind, + }, + reporter::Reporter, +}; +use crate::{Session, session::execute::spawn::SpawnTrackResult}; + +/// Internal error type used to abort execution when errors occur. +/// This error is swallowed in Session::execute and never exposed externally. +#[derive(Debug)] +struct ExecutionAborted; + +struct ExecutionContext<'a> { + indexed_task_graph: Option<&'a IndexedTaskGraph>, + event_handler: &'a mut dyn Reporter, + current_execution_id: ExecutionId, + cache: &'a ExecutionCache, + /// All relative paths in cache are relative to this base path + cache_base_path: &'a Arc, +} + +impl ExecutionContext<'_> { + async fn execute_item_kind( + &mut self, + display: Option<&ExecutionItemDisplay>, + item_kind: &ExecutionItemKind, + ) -> Result<(), ExecutionAborted> { + match item_kind { + ExecutionItemKind::Expanded(graph) => { + // clone for reversing edges and removing nodes + let mut graph: DiGraph<&TaskExecution, (), ExecutionIx> = + graph.map(|_, task_execution| task_execution, |_, ()| ()); + + // To be consistent with the package graph in vite_package_manager and the dependency graph definition in Wikipedia + // https://en.wikipedia.org/wiki/Dependency_graph, we construct the graph with edges from dependents to dependencies + // e.g. A -> B means A depends on B + // + // For execution we need to reverse the edges first before topological sorting, + // so that tasks without dependencies are executed first + graph.reverse(); // Run tasks without dependencies first + + // Always use topological sort to ensure the correct order of execution + // or the task dependencies declaration is meaningless + let node_indices = match toposort(&graph, None) { + Ok(ok) => ok, + Err(cycle) => { + // Follow standard error pattern: Start event, then Error event + let execution_id = self.current_execution_id; + self.current_execution_id = self.current_execution_id.next(); + + // Emit Start event for cycle detection error + // display is None for top-level execution (no parent task) + // display is Some for nested execution (within a parent task) + // Caching is disabled when cycle dependencies are detected + self.event_handler.handle_event(ExecutionEvent { + execution_id, + kind: ExecutionEventKind::Start { + display: display.cloned(), + cache_status: CacheStatus::Disabled( + CacheDisabledReason::CycleDetected, + ), + }, + }); + + self.event_handler.handle_event(ExecutionEvent { + execution_id, + kind: ExecutionEventKind::Error { + message: format!("Cycle dependencies detected: {cycle:?}"), + }, + }); + + return Err(ExecutionAborted); + } + }; + + let ordered_executions = + node_indices.into_iter().map(|id| graph.remove_node(id).unwrap()); + for task_execution in ordered_executions { + for item in task_execution.items.iter() { + match &item.kind { + ExecutionItemKind::Leaf(leaf_kind) => { + self.execute_leaf(Some(&item.execution_item_display), leaf_kind) + .boxed_local() + .await?; + } + ExecutionItemKind::Expanded(_) => { + self.execute_item_kind( + Some(&item.execution_item_display), + &item.kind, + ) + .boxed_local() + .await?; + } + } + } + } + } + ExecutionItemKind::Leaf(leaf_execution_kind) => { + self.execute_leaf(display, leaf_execution_kind).await?; + } + } + Ok(()) + } + + async fn execute_leaf( + &mut self, + display: Option<&ExecutionItemDisplay>, + leaf_execution_kind: &LeafExecutionKind, + ) -> Result<(), ExecutionAborted> { + let execution_id = self.current_execution_id; + self.current_execution_id = self.current_execution_id.next(); + + match leaf_execution_kind { + LeafExecutionKind::InProcess(in_process_execution) => { + // Emit Start event with cache_status for in-process (built-in) commands + // Caching is disabled for built-in commands + self.event_handler.handle_event(ExecutionEvent { + execution_id, + kind: ExecutionEventKind::Start { + display: display.cloned(), + cache_status: CacheStatus::Disabled( + CacheDisabledReason::InProcessExecution, + ), + }, + }); + + // Execute the in-process command + let execution_output = in_process_execution.execute().await; + self.event_handler.handle_event(ExecutionEvent { + execution_id, + kind: ExecutionEventKind::Output { + kind: OutputKind::Stdout, + content: execution_output.stdout.into(), + }, + }); + + // Emit Finish WITHOUT cache_status (already in Start event) + self.event_handler.handle_event(ExecutionEvent { + execution_id, + kind: ExecutionEventKind::Finish { status: Some(0) }, + }); + } + LeafExecutionKind::Spawn(spawn_execution) => { + self.execute_spawn(execution_id, display, spawn_execution).await?; + } + } + Ok(()) + } + + async fn execute_spawn( + &mut self, + execution_id: ExecutionId, + display: Option<&ExecutionItemDisplay>, + spawn_execution: &SpawnExecution, + ) -> Result<(), ExecutionAborted> { + let cache_metadata = spawn_execution.cache_metadata.as_ref(); + + // 1. Determine cache status FIRST by trying cache hit + // We need to know the status before emitting Start event so users + // see cache status immediately when execution begins + let (cache_status, cached_value) = if let Some(cache_metadata) = cache_metadata { + match self.cache.try_hit(cache_metadata, &*self.cache_base_path).await { + Ok(Ok(cached)) => ( + // Cache hit - we can replay the cached outputs + CacheStatus::Hit { replayed_duration: cached.duration }, + Some(cached), + ), + Ok(Err(cache_miss)) => ( + // Cache miss - includes detailed reason (NotFound or FingerprintMismatch) + CacheStatus::Miss(cache_miss), + None, + ), + Err(err) => { + // Cache lookup error - emit error and abort + self.event_handler.handle_event(ExecutionEvent { + execution_id, + kind: ExecutionEventKind::Error { + message: format!("Cache lookup failed: {err}"), + }, + }); + return Err(ExecutionAborted); + } + } + } else { + // No cache metadata provided - caching is disabled for this task + (CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata), None) + }; + + // 2. NOW emit Start event with cache_status (ALWAYS emit Start) + // This ensures all spawn executions emit Start, including cache hits + // (previously cache hits didn't emit Start at all) + self.event_handler.handle_event(ExecutionEvent { + execution_id, + kind: ExecutionEventKind::Start { display: display.cloned(), cache_status }, + }); + + // 3. If cache hit, replay outputs and return early + // No need to actually execute the command - just replay what was cached + if let Some(cached) = cached_value { + for output in cached.std_outputs.iter() { + self.event_handler.handle_event(ExecutionEvent { + execution_id, + kind: ExecutionEventKind::Output { + kind: match output.kind { + SpawnOutputKind::StdOut => OutputKind::Stdout, + SpawnOutputKind::StdErr => OutputKind::Stderr, + }, + content: output.content.clone().into(), + }, + }); + } + // Emit Finish without cache_status (status already in Start event) + self.event_handler.handle_event(ExecutionEvent { + execution_id, + kind: ExecutionEventKind::Finish { status: Some(0) }, + }); + return Ok(()); + } + + // 4. Execute spawn (cache miss or disabled) + // Track file system access if caching is enabled (for future cache updates) + let mut track_result_with_cache_metadata = if let Some(cache_metadata) = cache_metadata { + Some((SpawnTrackResult::default(), cache_metadata)) + } else { + None + }; + + // Execute command with tracking, emitting output events in real-time + let result = match spawn_with_tracking( + &spawn_execution.spawn_command, + &*self.cache_base_path, + |kind, content| { + self.event_handler.handle_event(ExecutionEvent { + execution_id, + kind: ExecutionEventKind::Output { + kind: match kind { + SpawnOutputKind::StdOut => OutputKind::Stdout, + SpawnOutputKind::StdErr => OutputKind::Stderr, + }, + content, + }, + }); + }, + track_result_with_cache_metadata.as_mut().map(|(track_result, _)| track_result), + ) + .await + { + Ok(result) => result, + Err(err) => { + self.event_handler.handle_event(ExecutionEvent { + execution_id, + kind: ExecutionEventKind::Error { + message: format!("Failed to spawn process: {err}"), + }, + }); + return Err(ExecutionAborted); + } + }; + + // 5. Update cache if successful + // Only update cache if: (a) tracking was enabled, and (b) execution succeeded + if let Some((track_result, cache_metadata)) = track_result_with_cache_metadata + && result.exit_status.success() + { + let fingerprint_ignores = + cache_metadata.spawn_fingerprint.fingerprint_ignores().map(|v| v.as_slice()); + match PostRunFingerprint::create( + &track_result.path_reads, + &*self.cache_base_path, + fingerprint_ignores, + ) { + Ok(post_run_fingerprint) => { + let cache_value = CommandCacheValue { + post_run_fingerprint, + std_outputs: track_result.std_outputs.clone().into(), + duration: result.duration, + }; + if let Err(err) = self.cache.update(cache_metadata, cache_value).await { + self.event_handler.handle_event(ExecutionEvent { + execution_id, + kind: ExecutionEventKind::Error { + message: format!("Failed to update cache: {err}"), + }, + }); + return Err(ExecutionAborted); + } + } + Err(err) => { + self.event_handler.handle_event(ExecutionEvent { + execution_id, + kind: ExecutionEventKind::Error { + message: format!("Failed to create post-run fingerprint: {err}"), + }, + }); + return Err(ExecutionAborted); + } + } + } + + // 6. Emit finish WITHOUT cache_status + // Cache status was already emitted in Start event + self.event_handler.handle_event(ExecutionEvent { + execution_id, + kind: ExecutionEventKind::Finish { status: result.exit_status.code() }, + }); + + Ok(()) + } +} + +impl<'a, CustomSubcommand> Session<'a, CustomSubcommand> { + pub async fn execute( + &self, + plan: ExecutionPlan, + mut reporter: Box, + ) -> anyhow::Result<()> { + let mut execution_context = ExecutionContext { + indexed_task_graph: self.lazy_task_graph.try_get(), + event_handler: &mut *reporter, + current_execution_id: ExecutionId::zero(), + cache: &self.cache, + cache_base_path: &self.workspace_path, + }; + + // Execute and swallow ExecutionAborted error + // display is None for top-level execution + let _ = execution_context.execute_item_kind(None, plan.root_node()).await; + + // Always call post_execution, whether execution succeeded or failed + reporter.post_execution() + } +} diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs new file mode 100644 index 00000000..2f7aaa8e --- /dev/null +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -0,0 +1,223 @@ +//! Process spawning with file system tracking via fspy. + +use std::{ + collections::hash_map::Entry, + process::{ExitStatus, Stdio}, + time::{Duration, Instant}, +}; + +use bincode::{Decode, Encode}; +use bstr::BString; +use fspy::AccessMode; +use serde::Serialize; +use tokio::io::AsyncReadExt as _; +use vite_path::{AbsolutePath, RelativePathBuf}; +use vite_task_plan::SpawnCommand; + +use crate::collections::HashMap; + +/// Path read access info +#[derive(Debug, Clone, Copy)] +pub struct PathRead { + pub read_dir_entries: bool, +} + +/// Path write access info +#[derive(Debug, Clone, Copy)] +pub struct PathWrite; + +/// Output kind for stdout/stderr +#[derive(Debug, PartialEq, Eq, Clone, Copy, Encode, Decode, Serialize)] +pub enum OutputKind { + StdOut, + StdErr, +} + +/// Output chunk with stream kind +#[derive(Debug, Encode, Decode, Serialize, Clone)] +pub struct StdOutput { + pub kind: OutputKind, + pub content: Vec, +} + +/// Result of spawning a process with file tracking +#[derive(Debug)] +pub struct SpawnResult { + pub exit_status: ExitStatus, + pub duration: Duration, +} + +/// Tracking result from a spawned process for caching +#[derive(Default, Debug)] +pub struct SpawnTrackResult { + /// captured stdout/stderr + pub std_outputs: Vec, + + /// Tracked path reads + pub path_reads: HashMap, + + /// Tracked path writes + pub path_writes: HashMap, +} + +/// Spawn a command with file system tracking via fspy. +/// +/// Returns the execution result including captured outputs, exit status, +/// and tracked file accesses. +/// +/// - `on_output` is called in real-time as stdout/stderr data arrives. +/// - `track_result` if provided, will be populated with captured outputs and path accesses for caching. If `None`, tracking is disabled. +pub async fn spawn_with_tracking( + spawn_command: &SpawnCommand, + workspace_root: &AbsolutePath, + mut on_output: F, + track_result: Option<&mut SpawnTrackResult>, +) -> anyhow::Result +where + F: FnMut(OutputKind, BString), +{ + let mut cmd = fspy::Command::new(spawn_command.program_path.as_path()); + cmd.args(spawn_command.args.iter().map(|arg| arg.as_str())); + cmd.envs(spawn_command.all_envs.iter()); + cmd.current_dir(&*spawn_command.cwd); + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + + /// The tracking state of the spawned process + enum TrackingState<'a> { + /// Tacking is enabled, with the tracked child and result reference + Enabled(fspy::TrackedChild, &'a mut SpawnTrackResult), + + /// Tracking is disabled, with the tokio child process + Disabled(tokio::process::Child), + } + + let mut tracking_state = if let Some(track_result) = track_result { + // track_result is Some. Spawn with tracking enabled + TrackingState::Enabled(cmd.spawn().await?, track_result) + } else { + // Spawn without tracking + TrackingState::Disabled(cmd.into_tokio_command().spawn()?) + }; + + let mut child_stdout = match &mut tracking_state { + TrackingState::Enabled(tracked_child, _) => tracked_child.stdout.take().unwrap(), + TrackingState::Disabled(tokio_child) => tokio_child.stdout.take().unwrap(), + }; + let mut child_stderr = match &mut tracking_state { + TrackingState::Enabled(tracked_child, _) => tracked_child.stderr.take().unwrap(), + TrackingState::Disabled(tokio_child) => tokio_child.stderr.take().unwrap(), + }; + + let mut outputs = match &mut tracking_state { + TrackingState::Enabled(_, track_result) => Some(&mut track_result.std_outputs), + TrackingState::Disabled(_) => None, + }; + let mut stdout_buf = [0u8; 8192]; + let mut stderr_buf = [0u8; 8192]; + let mut stdout_done = false; + let mut stderr_done = false; + + let start = Instant::now(); + + // Helper closure to process output chunks + let mut process_output = |kind: OutputKind, content: Vec| { + // Emit event immediately + on_output(kind, content.clone().into()); + + // Store outputs for caching + if let Some(outputs) = &mut outputs { + // Merge consecutive outputs of the same kind for caching + if let Some(last) = outputs.last_mut() + && last.kind == kind + { + last.content.extend(&content); + } else { + outputs.push(StdOutput { kind, content }); + } + } + }; + + // Read from both stdout and stderr concurrently using select! + loop { + tokio::select! { + result = child_stdout.read(&mut stdout_buf), if !stdout_done => { + match result? { + 0 => stdout_done = true, + n => process_output(OutputKind::StdOut, stdout_buf[..n].to_vec()), + } + } + result = child_stderr.read(&mut stderr_buf), if !stderr_done => { + match result? { + 0 => stderr_done = true, + n => process_output(OutputKind::StdErr, stderr_buf[..n].to_vec()), + } + } + else => break, + } + } + + let (termination, track_result) = match tracking_state { + TrackingState::Enabled(tracked_child, track_result) => { + (tracked_child.wait_handle.await?, track_result) + } + TrackingState::Disabled(mut tokio_child) => { + return Ok(SpawnResult { + exit_status: tokio_child.wait().await?, + duration: start.elapsed(), + }); + } + }; + let duration = start.elapsed(); + + // Process path accesses + let path_reads = &mut track_result.path_reads; + let path_writes = &mut track_result.path_writes; + + for access in termination.path_accesses.iter() { + let relative_path = access + .path + .strip_path_prefix(workspace_root, |strip_result| { + let Ok(stripped_path) = strip_result else { + return None; + }; + Some(RelativePathBuf::new(stripped_path).map_err(|err| { + anyhow::anyhow!("Invalid relative path '{}': {}", stripped_path.display(), err) + })) + }) + .transpose()?; + + let Some(relative_path) = relative_path else { + // Ignore accesses outside the workspace + continue; + }; + + // Skip .git directory accesses (workaround for tools like oxlint) + if relative_path.as_path().strip_prefix(".git").is_ok() { + continue; + } + + if access.mode.contains(AccessMode::READ) { + path_reads.entry(relative_path.clone()).or_insert(PathRead { read_dir_entries: false }); + } + if access.mode.contains(AccessMode::WRITE) { + path_writes.insert(relative_path.clone(), PathWrite); + } + if access.mode.contains(AccessMode::READ_DIR) { + match path_reads.entry(relative_path) { + Entry::Occupied(mut occupied) => occupied.get_mut().read_dir_entries = true, + Entry::Vacant(vacant) => { + vacant.insert(PathRead { read_dir_entries: true }); + } + } + } + } + + tracing::debug!( + "spawn finished, path_reads: {}, path_writes: {}, exit_status: {}", + path_reads.len(), + path_writes.len(), + termination.status, + ); + + Ok(SpawnResult { exit_status: termination.status, duration }) +} diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs new file mode 100644 index 00000000..d184ee3f --- /dev/null +++ b/crates/vite_task/src/session/mod.rs @@ -0,0 +1,263 @@ +mod cache; +mod event; +mod execute; +pub mod reporter; + +// Re-export types that are part of the public API +use std::{ffi::OsStr, fmt::Debug, sync::Arc}; + +use cache::ExecutionCache; +pub use cache::{CacheMiss, FingerprintMismatch}; +use clap::{Parser, Subcommand}; +pub use event::ExecutionEvent; +pub use reporter::{LabeledReporter, Reporter}; +use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_str::Str; +use vite_task_graph::{IndexedTaskGraph, TaskGraph, TaskGraphLoadError, loader::UserConfigLoader}; +use vite_task_plan::{ + ExecutionPlan, TaskGraphLoader, TaskPlanErrorKind, get_path_env, + plan_request::{PlanRequest, SyntheticPlanRequest}, +}; +use vite_workspace::{WorkspaceRoot, find_workspace_root}; + +use crate::{ + cli::{ParsedTaskCLIArgs, TaskCLIArgs}, + collections::HashMap, +}; + +#[derive(Debug)] +enum LazyTaskGraph<'a> { + Uninitialized { workspace_root: WorkspaceRoot, config_loader: &'a dyn UserConfigLoader }, + Initialized(IndexedTaskGraph), +} + +impl LazyTaskGraph<'_> { + fn try_get(&self) -> Option<&IndexedTaskGraph> { + match self { + Self::Initialized(graph) => Some(graph), + _ => None, + } + } +} + +#[async_trait::async_trait(?Send)] +impl TaskGraphLoader for LazyTaskGraph<'_> { + async fn load_task_graph( + &mut self, + ) -> Result<&vite_task_graph::IndexedTaskGraph, TaskGraphLoadError> { + Ok(match self { + Self::Uninitialized { workspace_root, config_loader } => { + let graph = IndexedTaskGraph::load(workspace_root, *config_loader).await?; + *self = Self::Initialized(graph); + match self { + Self::Initialized(graph) => &*graph, + _ => unreachable!(), + } + } + Self::Initialized(graph) => &*graph, + }) + } +} + +pub struct SessionCallbacks<'a, CustomSubcommand> { + pub task_synthesizer: &'a mut (dyn TaskSynthesizer + 'a), + pub user_config_loader: &'a mut (dyn UserConfigLoader + 'a), +} + +#[async_trait::async_trait(?Send)] +pub trait TaskSynthesizer: Debug { + fn should_synthesize_for_program(&self, program: &str) -> bool; + async fn synthesize_task( + &mut self, + subcommand: CustomSubcommand, + path_env: Option<&Arc>, + cwd: &Arc, + ) -> anyhow::Result; +} + +#[derive(derive_more::Debug)] +#[debug(bound())] // Avoid requiring CustomSubcommand: Debug +struct PlanRequestParser<'a, CustomSubcommand> { + task_synthesizer: &'a mut (dyn TaskSynthesizer + 'a), +} + +impl PlanRequestParser<'_, CustomSubcommand> { + async fn get_plan_request_from_cli_args( + &mut self, + cli_args: ParsedTaskCLIArgs, + path_env: Option<&Arc>, + cwd: &Arc, + ) -> anyhow::Result { + match cli_args { + ParsedTaskCLIArgs::BuiltIn(vite_task_subcommand) => { + Ok(vite_task_subcommand.into_plan_request(cwd)?) + } + ParsedTaskCLIArgs::Custom(custom_subcommand) => { + let synthetic_plan_request = + self.task_synthesizer.synthesize_task(custom_subcommand, path_env, cwd).await?; + Ok(PlanRequest::Synthetic(synthetic_plan_request)) + } + } + } +} + +#[async_trait::async_trait(?Send)] +impl vite_task_plan::PlanRequestParser + for PlanRequestParser<'_, CustomSubcommand> +{ + async fn get_plan_request( + &mut self, + program: &str, + args: &[Str], + path_env: Option<&Arc>, + cwd: &Arc, + ) -> anyhow::Result> { + Ok( + if self.task_synthesizer.should_synthesize_for_program(program) + && let Some(subcommand) = args.first() + && ParsedTaskCLIArgs::::has_subcommand(subcommand) + { + let cli_args = ParsedTaskCLIArgs::::try_parse_from( + std::iter::once(program).chain(args.iter().map(Str::as_str)), + )?; + Some(self.get_plan_request_from_cli_args(cli_args, path_env, cwd).await?) + } else { + None + }, + ) + } +} + +/// Represents a vite task session for planning and executing tasks. A process typically has one session. +/// +/// A session manages task graph loading internally and provides non-consuming methods to plan and/or execute tasks (allows multiple plans/executions per session). +pub struct Session<'a, CustomSubcommand> { + workspace_path: Arc, + /// A session doesn't necessarily load the task graph immediately. + /// The task graph is loaded on-demand and cached for future use. + lazy_task_graph: LazyTaskGraph<'a>, + + envs: HashMap, Arc>, + cwd: Arc, + + plan_request_parser: PlanRequestParser<'a, CustomSubcommand>, + + cache: ExecutionCache, +} + +fn get_cache_path_of_workspace(workspace_root: &AbsolutePath) -> AbsolutePathBuf { + if let Ok(env_cache_path) = std::env::var("VITE_CACHE_PATH") { + AbsolutePathBuf::new(env_cache_path.into()).expect("Cache path should be absolute") + } else { + workspace_root.join("node_modules/.vite/task-cache") + } +} + +impl<'a, CustomSubcommand> Session<'a, CustomSubcommand> { + /// Initialize a session with real environment variables and cwd + pub fn init(callbacks: SessionCallbacks<'a, CustomSubcommand>) -> anyhow::Result { + let envs = std::env::vars_os() + .map(|(k, v)| (Arc::::from(k.as_os_str()), Arc::::from(v.as_os_str()))) + .collect(); + Self::init_with(envs, vite_path::current_dir()?.into(), callbacks) + } + + pub async fn ensure_task_graph_loaded( + &mut self, + ) -> Result<&IndexedTaskGraph, TaskGraphLoadError> { + self.lazy_task_graph.load_task_graph().await + } + + /// Initialize a session with custom cwd, environment variables. Useful for testing. + pub fn init_with( + envs: HashMap, Arc>, + cwd: Arc, + callbacks: SessionCallbacks<'a, CustomSubcommand>, + ) -> anyhow::Result { + let (workspace_root, _) = find_workspace_root(&cwd)?; + let cache_path = get_cache_path_of_workspace(&workspace_root.path); + + if !cache_path.as_path().exists() + && let Some(cache_dir) = cache_path.as_path().parent() + { + tracing::info!("Creating task cache directory at {}", cache_dir.display()); + std::fs::create_dir_all(cache_dir)?; + } + let cache = ExecutionCache::load_from_path(cache_path)?; + Ok(Self { + workspace_path: Arc::clone(&workspace_root.path), + lazy_task_graph: LazyTaskGraph::Uninitialized { + workspace_root, + config_loader: callbacks.user_config_loader, + }, + envs, + cwd, + plan_request_parser: PlanRequestParser { task_synthesizer: callbacks.task_synthesizer }, + cache, + }) + } + + pub fn cache(&self) -> &ExecutionCache { + &self.cache + } + + pub fn workspace_path(&self) -> Arc { + Arc::clone(&self.workspace_path) + } + + pub fn task_graph(&self) -> Option<&TaskGraph> { + match &self.lazy_task_graph { + LazyTaskGraph::Initialized(graph) => Some(graph.task_graph()), + _ => None, + } + } +} + +impl<'a, CustomSubcommand: clap::Subcommand> Session<'a, CustomSubcommand> { + pub async fn plan_synthetic_task( + &mut self, + synthetic_plan_request: SyntheticPlanRequest, + ) -> Result { + let plan = ExecutionPlan::plan( + PlanRequest::Synthetic(synthetic_plan_request), + &self.workspace_path, + &self.cwd, + &self.envs, + &mut self.plan_request_parser, + &mut self.lazy_task_graph, + ) + .await?; + Ok(plan) + } + + pub async fn plan_from_cli( + &mut self, + cwd: Arc, + cli_args: TaskCLIArgs, + ) -> Result { + let path_env = get_path_env(&self.envs); + let plan_request = self + .plan_request_parser + .get_plan_request_from_cli_args(cli_args.parsed, path_env, &cwd) + .await + .map_err(|error| { + TaskPlanErrorKind::ParsePlanRequestError { + error, + program: cli_args.original[0].clone(), + args: cli_args.original.iter().skip(1).cloned().collect(), + cwd: Arc::clone(&cwd), + } + .with_empty_call_stack() + })?; + let plan = ExecutionPlan::plan( + plan_request, + &self.workspace_path, + &cwd, + &self.envs, + &mut self.plan_request_parser, + &mut self.lazy_task_graph, + ) + .await?; + Ok(plan) + } +} diff --git a/crates/vite_task/src/session/reporter.rs b/crates/vite_task/src/session/reporter.rs new file mode 100644 index 00000000..b881b95a --- /dev/null +++ b/crates/vite_task/src/session/reporter.rs @@ -0,0 +1,505 @@ +//! LabeledReporter event handler for rendering execution events. + +use std::{ + collections::HashSet, + io::Write, + sync::{Arc, LazyLock}, + time::Duration, +}; + +use owo_colors::{Style, Styled}; +use vite_path::AbsolutePath; + +use super::{ + cache::{format_cache_status_inline, format_cache_status_summary}, + event::{CacheStatus, ExecutionEvent, ExecutionEventKind, ExecutionId, ExecutionItemDisplay}, +}; + +/// Wrap of `OwoColorize` that ignores style if `NO_COLOR` is set. +trait ColorizeExt { + fn style(&self, style: Style) -> Styled<&Self>; +} + +impl ColorizeExt for T { + fn style(&self, style: Style) -> Styled<&Self> { + static NO_COLOR: LazyLock = + LazyLock::new(|| std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty())); + owo_colors::OwoColorize::style(self, if *NO_COLOR { Style::new() } else { style }) + } +} + +/// Trait for handling execution events and reporting results +pub trait Reporter { + /// Handle an execution event (start, output, error, finish) + fn handle_event(&mut self, event: ExecutionEvent); + + /// Called after execution completes (whether successful or not) + /// Returns Err if execution failed due to errors + fn post_execution(self: Box) -> anyhow::Result<()>; +} + +const COMMAND_STYLE: Style = Style::new().cyan(); +const CACHE_MISS_STYLE: Style = Style::new().purple(); + +/// Information tracked for each execution +#[derive(Debug)] +struct ExecutionInfo { + display: Option, + cache_status: CacheStatus, // Non-optional, determined at Start + exit_status: Option, + error_message: Option, +} + +/// Statistics for the execution summary +#[derive(Default)] +struct ExecutionStats { + cache_hits: usize, + cache_misses: usize, + cache_disabled: usize, + failed: usize, +} + +/// Event handler that renders execution events in labeled format. +/// +/// # Output Modes +/// +/// The reporter has different output modes based on configuration and execution context: +/// +/// ## Normal Mode (default) +/// - Prints command lines with cache status indicators during execution +/// - Shows full summary with Statistics and Task Details at the end +/// +/// ## Silent Cache Hit Mode (`silent_if_cache_hit = true`) +/// - Suppresses command lines and output for cache hit executions +/// - Useful for faster, cleaner output when many tasks are cached +/// +/// ## Hidden Summary Mode (`hide_summary = true`) +/// - Skips printing the execution summary entirely +/// - Useful for programmatic usage or when summary is not needed +/// +/// ## Simplified Summary for Built-in Commands +/// - When a single built-in command (e.g., `vite lint`) is executed: +/// - Skips full summary (no Statistics/Task Details sections) +/// - Shows only cache status (except for "NotFound" which is hidden for clean first-run output) +/// - Results in clean output showing just the command's stdout/stderr +pub struct LabeledReporter { + writer: W, + workspace_path: Arc, + executions: Vec, + stats: ExecutionStats, + first_error: Option, + + /// When true, suppresses command line and output for cache hit executions + silent_if_cache_hit: bool, + + /// When true, skips printing the execution summary at the end + hide_summary: bool, + + /// Tracks which executions are cache hits (for silent_if_cache_hit mode) + cache_hit_executions: HashSet, +} + +impl LabeledReporter { + pub fn new(writer: W, workspace_path: Arc) -> Self { + Self { + writer, + workspace_path, + executions: Vec::new(), + stats: ExecutionStats::default(), + first_error: None, + silent_if_cache_hit: false, + hide_summary: false, + cache_hit_executions: HashSet::new(), + } + } + + /// Set the silent_if_cache_hit option + pub fn set_silent_if_cache_hit(&mut self, silent_if_cache_hit: bool) { + self.silent_if_cache_hit = silent_if_cache_hit; + } + + /// Set the hide_summary option + pub fn set_hide_summary(&mut self, hide_summary: bool) { + self.hide_summary = hide_summary; + } + + fn handle_start( + &mut self, + execution_id: ExecutionId, + display: Option, + cache_status: CacheStatus, + ) { + // Update statistics immediately based on cache status + match &cache_status { + CacheStatus::Hit { .. } => { + self.stats.cache_hits += 1; + // Track cache hit executions for silent mode + if self.silent_if_cache_hit { + self.cache_hit_executions.insert(execution_id); + } + } + CacheStatus::Miss(_) => self.stats.cache_misses += 1, + CacheStatus::Disabled(_) => self.stats.cache_disabled += 1, + } + + // Handle None display case - just store minimal info + // This occurs for top-level execution (no parent task) + let Some(display) = display else { + self.executions.push(ExecutionInfo { + display: None, + cache_status, + exit_status: None, + error_message: None, + }); + return; + }; + + // Compute cwd relative to workspace root + let cwd_relative = if let Ok(Some(rel)) = display.cwd.strip_prefix(&self.workspace_path) { + rel.as_str().to_string() + } else { + String::new() + }; + + let cwd_str = + if cwd_relative.is_empty() { String::new() } else { format!("{cwd_relative}/") }; + let command_str = format!("{cwd_str}$ {}", display.command); + + // Skip printing if silent_if_cache_hit is enabled and this is a cache hit + let should_print = + !self.silent_if_cache_hit || !matches!(cache_status, CacheStatus::Hit { .. }); + + if should_print { + // Print command with optional inline cache status + // Use display module for plain text, apply styling here + if let Some(inline_status) = format_cache_status_inline(&cache_status) { + // Apply styling based on cache status type + let styled_status = match &cache_status { + CacheStatus::Hit { .. } => inline_status.style(Style::new().green().dimmed()), + CacheStatus::Miss(_) => inline_status.style(CACHE_MISS_STYLE.dimmed()), + CacheStatus::Disabled(_) => inline_status.style(Style::new().bright_black()), + }; + let _ = + writeln!(self.writer, "{} {}", command_str.style(COMMAND_STYLE), styled_status); + } else { + let _ = writeln!(self.writer, "{}", command_str.style(COMMAND_STYLE)); + } + } + + // Store execution info for summary + self.executions.push(ExecutionInfo { + display: Some(display), + cache_status, + exit_status: None, + error_message: None, + }); + } + + fn handle_error(&mut self, _execution_id: ExecutionId, message: String) { + // Display error inline (in red, with error icon) + let _ = writeln!( + self.writer, + "{} {}", + "✗".style(Style::new().red().bold()), + message.style(Style::new().red()) + ); + + // Track first error + if self.first_error.is_none() { + self.first_error = Some(message.clone()); + } + + // Track error for summary + if let Some(exec) = self.executions.last_mut() { + exec.error_message = Some(message); + } + + self.stats.failed += 1; + } + + fn handle_finish(&mut self, _execution_id: ExecutionId, status: Option) { + // Update failure statistics + if let Some(s) = status { + if s != 0 { + self.stats.failed += 1; + } + } + + // Update execution info exit status + if let Some(exec) = self.executions.last_mut() { + exec.exit_status = status; + } + } + + /// Print execution summary after all events + pub fn print_summary(&mut self) { + let total = self.executions.len(); + let cache_hits = self.stats.cache_hits; + let cache_misses = self.stats.cache_misses; + let cache_disabled = self.stats.cache_disabled; + let failed = self.stats.failed; + + // Print summary header with decorative line + let _ = writeln!(self.writer); + let _ = writeln!( + self.writer, + "{}", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".style(Style::new().bright_black()) + ); + let _ = writeln!( + self.writer, + "{}", + " Vite+ Task Runner • Execution Summary".style(Style::new().bold().bright_white()) + ); + let _ = writeln!( + self.writer, + "{}", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".style(Style::new().bright_black()) + ); + let _ = writeln!(self.writer); + + // Print statistics + let cache_disabled_str = if cache_disabled > 0 { + format!("• {cache_disabled} cache disabled") + .style(Style::new().bright_black()) + .to_string() + } else { + String::new() + }; + + let failed_str = if failed > 0 { + format!("• {failed} failed").style(Style::new().red()).to_string() + } else { + String::new() + }; + + let _ = writeln!( + self.writer, + "{} {} {} {} {} {}", + "Statistics:".style(Style::new().bold()), + format!(" {total} tasks").style(Style::new().bright_white()), + format!("• {cache_hits} cache hits").style(Style::new().green()), + format!("• {cache_misses} cache misses").style(CACHE_MISS_STYLE), + cache_disabled_str, + failed_str + ); + + // Calculate cache hit rate + let cache_rate = if total > 0 { + (f64::from(cache_hits as u32) / total as f64 * 100.0) as u32 + } else { + 0 + }; + + // Calculate total time saved + let total_saved: Duration = self + .executions + .iter() + .filter_map(|exec| { + if let CacheStatus::Hit { replayed_duration } = &exec.cache_status { + Some(*replayed_duration) + } else { + None + } + }) + .sum(); + + let _ = write!( + self.writer, + "{} {} cache hit rate", + "Performance:".style(Style::new().bold()), + format_args!("{cache_rate}%").style(if cache_rate >= 75 { + Style::new().green().bold() + } else if cache_rate >= 50 { + CACHE_MISS_STYLE + } else { + Style::new().red() + }) + ); + + if total_saved > Duration::ZERO { + let _ = write!( + self.writer, + ", {:.2?} saved in total", + total_saved.style(Style::new().green().bold()) + ); + } + let _ = writeln!(self.writer); + let _ = writeln!(self.writer); + + // Detailed task results + let _ = writeln!(self.writer, "{}", "Task Details:".style(Style::new().bold())); + let _ = writeln!( + self.writer, + "{}", + "────────────────────────────────────────────────".style(Style::new().bright_black()) + ); + + for (idx, exec) in self.executions.iter().enumerate() { + // Skip if no display info + let Some(ref display) = exec.display else { + continue; + }; + + let task_name = &display.task_display.task_name; + + // Task name and index + let _ = write!( + self.writer, + " {} {}", + format!("[{}]", idx + 1).style(Style::new().bright_black()), + task_name.style(Style::new().bright_white().bold()) + ); + + // Command + let _ = write!(self.writer, ": {}", display.command.style(COMMAND_STYLE)); + + // Execution result icon + match exec.exit_status { + Some(0) => { + let _ = write!(self.writer, " {}", "✓".style(Style::new().green().bold())); + } + Some(code) => { + let _ = write!( + self.writer, + " {} {}", + "✗".style(Style::new().red().bold()), + format!("(exit code: {code})").style(Style::new().red()) + ); + } + None => { + let _ = write!(self.writer, " {}", "?".style(Style::new().bright_black())); + } + } + let _ = writeln!(self.writer); + + // Cache status details - use display module for plain text, apply styling here + let cache_summary = format_cache_status_summary(&exec.cache_status); + let styled_summary = match &exec.cache_status { + CacheStatus::Hit { .. } => cache_summary.style(Style::new().green()), + CacheStatus::Miss(_) => cache_summary.style(CACHE_MISS_STYLE), + CacheStatus::Disabled(_) => cache_summary.style(Style::new().bright_black()), + }; + let _ = writeln!(self.writer, " {}", styled_summary); + + // Error message if present + if let Some(ref error_msg) = exec.error_message { + let _ = writeln!( + self.writer, + " {} {}", + "✗ Error:".style(Style::new().red().bold()), + error_msg.style(Style::new().red()) + ); + } + + // Add spacing between tasks except for the last one + if idx < self.executions.len() - 1 { + let _ = writeln!( + self.writer, + " {}", + "·······················································" + .style(Style::new().bright_black()) + ); + } + } + + let _ = writeln!( + self.writer, + "{}", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".style(Style::new().bright_black()) + ); + } + + /// Print simplified cache status for single built-in commands + /// + /// Shows cache status for built-in commands, but hides "no previous cache entry found" + /// to keep first-run output clean (just shows the command's stdout/stderr). + fn print_simple_cache_status(&mut self) { + if let Some(exec) = self.executions.first() { + // Skip printing for "NotFound" cache miss - keeps first-run output clean + use super::cache::CacheMiss; + if matches!(&exec.cache_status, CacheStatus::Miss(CacheMiss::NotFound)) { + return; + } + + let _ = writeln!(self.writer); + + // Show cache status for hits, meaningful misses, and disabled cache + let cache_summary = format_cache_status_summary(&exec.cache_status); + let styled_summary = match &exec.cache_status { + CacheStatus::Hit { .. } => cache_summary.style(Style::new().green()), + CacheStatus::Miss(_) => cache_summary.style(CACHE_MISS_STYLE), + CacheStatus::Disabled(_) => cache_summary.style(Style::new().bright_black()), + }; + let _ = writeln!(self.writer, "{}", styled_summary); + } + } +} + +impl Reporter for LabeledReporter { + fn handle_event(&mut self, event: ExecutionEvent) { + match event.kind { + ExecutionEventKind::Start { display, cache_status } => { + self.handle_start(event.execution_id, display, cache_status); + } + ExecutionEventKind::Output { content, .. } => { + // Skip output if silent_if_cache_hit is enabled and this execution is a cache hit + if self.silent_if_cache_hit + && self.cache_hit_executions.contains(&event.execution_id) + { + return; + } + let _ = self.writer.write_all(&content); + let _ = self.writer.flush(); + } + ExecutionEventKind::Error { message } => { + self.handle_error(event.execution_id, message); + } + ExecutionEventKind::Finish { status } => { + self.handle_finish(event.execution_id, status); + } + } + } + + fn post_execution(mut self: Box) -> anyhow::Result<()> { + // Check if execution was aborted due to error + if let Some(error_msg) = &self.first_error { + // Print separator + let _ = writeln!(self.writer); + let _ = writeln!( + self.writer, + "{}", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + .style(Style::new().bright_black()) + ); + + // Print error abort message + let _ = writeln!( + self.writer, + "{} {}", + "Execution aborted due to error:".style(Style::new().red().bold()), + error_msg.style(Style::new().red()) + ); + + let _ = writeln!( + self.writer, + "{}", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + .style(Style::new().bright_black()) + ); + + return Err(anyhow::anyhow!("Execution aborted: {}", error_msg)); + } + + // No errors - print summary if not hidden + if !self.hide_summary { + // Special case: single built-in command (no display info) + if self.executions.len() == 1 && self.executions[0].display.is_none() { + self.print_simple_cache_status(); + } else { + self.print_summary(); + } + } + Ok(()) + } +} diff --git a/crates/vite_task/src/types.rs b/crates/vite_task/src/types.rs deleted file mode 100644 index 2d458c23..00000000 --- a/crates/vite_task/src/types.rs +++ /dev/null @@ -1,7 +0,0 @@ -use std::collections::HashMap; - -/// Result from resolving a command -pub struct ResolveCommandResult { - pub bin_path: String, - pub envs: HashMap, -} diff --git a/crates/vite_task/src/ui.rs b/crates/vite_task/src/ui.rs deleted file mode 100644 index 5d6351cd..00000000 --- a/crates/vite_task/src/ui.rs +++ /dev/null @@ -1,414 +0,0 @@ -use std::{fmt::Display, sync::LazyLock, time::Duration}; - -use itertools::Itertools; -use owo_colors::{Style, Styled}; -use vite_path::RelativePath; - -use crate::{ - cache::{CacheMiss, FingerprintMismatch}, - config::{DisplayOptions, ResolvedTask}, - fingerprint::PostRunFingerprintMismatch, - schedule::{CacheStatus, ExecutionFailure, ExecutionSummary, PreExecutionStatus}, -}; - -/// Wrap of `OwoColorize` that ignores style if `NO_COLOR` is set. -trait ColorizeExt { - fn style(&self, style: Style) -> Styled<&Self>; -} -impl ColorizeExt for T { - fn style(&self, style: Style) -> Styled<&Self> { - static NO_COLOR: LazyLock = - LazyLock::new(|| std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty())); - owo_colors::OwoColorize::style(self, if *NO_COLOR { Style::new() } else { style }) - } -} - -const COMMAND_STYLE: Style = Style::new().cyan(); -const CACHE_MISS_STYLE: Style = Style::new().purple(); - -pub fn get_display_command(display_options: DisplayOptions, task: &ResolvedTask) -> Option { - let display_command = if display_options.hide_command { - if let Ok(outer_command) = std::env::var("VITE_OUTER_COMMAND") { - outer_command - } else { - return None; - } - } else { - task.resolved_command.fingerprint.command.to_string() - }; - - let cwd = task.resolved_command.fingerprint.cwd.as_str(); - let cwd_str = if cwd.is_empty() { format_args!("") } else { format_args!("~/{cwd}") }; - Some(format!("{cwd_str}$ {display_command}")) -} - -/// Displayed before the task is executed -impl Display for PreExecutionStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let display_command = self.display_command.as_ref().map(|cmd| cmd.style(COMMAND_STYLE)); - - // Print cache status with improved, shorter messages - match &self.cache_status { - CacheStatus::CacheMiss(CacheMiss::NotFound) => { - // No message for "Cache not found" as requested - tracing::debug!("{}", "Cache not found".style(CACHE_MISS_STYLE)); - if let Some(display_command) = &display_command { - writeln!(f, "{display_command}")?; - } - } - CacheStatus::CacheMiss(CacheMiss::FingerprintMismatch(mismatch)) => { - if let Some(display_command) = &display_command { - write!(f, "{display_command} ")?; - } - - let current = &self.task.resolved_command.fingerprint; - // Short, precise message about cache miss - let reason = match mismatch { - FingerprintMismatch::CommandFingerprintMismatch(previous) => { - // For now, just say "command changed" for any command fingerprint mismatch - // The detailed analysis will be in the summary - if previous.command != current.command { - "command changed".to_string() - } else if previous.cwd != current.cwd { - "working directory changed".to_string() - } else if previous.envs_without_pass_through - != current.envs_without_pass_through - || previous.pass_through_envs != current.pass_through_envs - { - "envs changed".to_string() - } else { - "command configuration changed".to_string() - } - } - FingerprintMismatch::PostRunFingerprintMismatch( - PostRunFingerprintMismatch::InputContentChanged { path }, - ) => { - format!("content of input '{path}' changed") - } - }; - writeln!( - f, - "{}", - format_args!( - "{}{}{}", - if display_command.is_some() { "(" } else { "" }, - format_args!("✗ cache miss: {}, executing", reason), - if display_command.is_some() { ")" } else { "" }, - ) - .style(CACHE_MISS_STYLE.dimmed()) - )?; - } - CacheStatus::CacheHit { .. } => { - if !self.display_options.ignore_replay { - if let Some(display_command) = &display_command { - write!(f, "{display_command} ")?; - } - writeln!( - f, - "{}", - format_args!( - "{}{}{}", - if display_command.is_some() { "(" } else { "" }, - "✓ cache hit, replaying", - if display_command.is_some() { ")" } else { "" }, - ) - .style(Style::new().green().dimmed()) - )?; - } - } - } - Ok(()) - } -} - -/// Displayed after all tasks have been executed -impl Display for ExecutionSummary { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // if *IS_IN_CLI_TEST { - // // No summary in test mode - // return Ok(()); - // } - - // Calculate statistics - let total = self.execution_statuses.len(); - let mut cache_hits = 0; - let mut cache_misses = 0; - let mut skipped = 0; - let mut failed = 0; - - for status in &self.execution_statuses { - match &status.pre_execution_status.cache_status { - CacheStatus::CacheHit { .. } => cache_hits += 1, - CacheStatus::CacheMiss(_) => cache_misses += 1, - } - - match &status.execution_result { - Ok(exit_status) if *exit_status != 0 => failed += 1, - Err(ExecutionFailure::SkippedDueToFailedDependency) => skipped += 1, - _ => {} - } - } - - // Print summary header with decorative line - writeln!(f)?; - writeln!( - f, - "{}", - "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".style(Style::new().bright_black()) - )?; - writeln!( - f, - "{}", - " Vite+ Task Runner • Execution Summary".style(Style::new().bold().bright_white()) - )?; - writeln!( - f, - "{}", - "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".style(Style::new().bright_black()) - )?; - writeln!(f)?; - - // Print statistics - writeln!( - f, - "{} {} {} {} {}", - "Statistics:".style(Style::new().bold()), - format!(" {total} tasks").style(Style::new().bright_white()), - format!("• {cache_hits} cache hits").style(Style::new().green()), - format!("• {cache_misses} cache misses").style(CACHE_MISS_STYLE), - if failed > 0 { - format!("• {failed} failed").style(Style::new().red()).to_string() - } else if skipped > 0 { - format!("• {skipped} skipped").style(Style::new().bright_black()).to_string() - } else { - String::new() - } - )?; - - let cache_rate = - if total > 0 { (f64::from(cache_hits) / total as f64 * 100.0) as u32 } else { 0 }; - - let total_duration = self - .execution_statuses - .iter() - .map(|status| { - if let CacheStatus::CacheHit { original_duration } = - &status.pre_execution_status.cache_status - { - *original_duration - } else { - Duration::ZERO - } - }) - .sum::(); - - write!( - f, - "{} {} cache hit rate", - "Performance:".style(Style::new().bold()), - format_args!("{cache_rate}%").style(if cache_rate >= 75 { - Style::new().green().bold() - } else if cache_rate >= 50 { - CACHE_MISS_STYLE - } else { - Style::new().red() - }) - )?; - if total_duration > Duration::ZERO { - write!( - f, - ", {:.2?} saved in total", - total_duration.style(Style::new().green().bold()) - )?; - } - writeln!(f)?; - writeln!(f)?; - - // Detailed task results - writeln!(f, "{}", "Task Details:".style(Style::new().bold()))?; - writeln!( - f, - "{}", - "────────────────────────────────────────────────".style(Style::new().bright_black()) - )?; - - for (idx, status) in self.execution_statuses.iter().enumerate() { - let task_name = status.pre_execution_status.task.display_name(); - - // Task name and index - write!( - f, - " {} {}", - format!("[{}]", idx + 1).style(Style::new().bright_black()), - task_name.style(Style::new().bright_white().bold()) - )?; - - if let Some(display_command) = &status.pre_execution_status.display_command { - write!(f, ": {}", display_command.style(COMMAND_STYLE))?; - } - - // Execution result icon and status - match &status.execution_result { - Ok(exit_status) if *exit_status == 0 => { - write!(f, " {}", "✓".style(Style::new().green().bold()))?; - } - Ok(exit_status) => { - write!( - f, - " {} {}", - "✗".style(Style::new().red().bold()), - format!("(exit code: {exit_status})").style(Style::new().red()) - )?; - } - Err(ExecutionFailure::SkippedDueToFailedDependency) => { - write!( - f, - " {} {}", - "⊘".style(Style::new().bright_black()), - "(skipped: dependency failed)".style(Style::new().bright_black()) - )?; - } - } - writeln!(f)?; - - // Cache status details (indented) - match &status.pre_execution_status.cache_status { - CacheStatus::CacheHit { original_duration } => { - writeln!( - f, - " {} {}", - "→ Cache hit - output replayed".style(Style::new().green()), - format!("- {original_duration:.2?} saved").style(Style::new().green()) - )?; - } - CacheStatus::CacheMiss(miss) => { - write!(f, " {}", "→ Cache miss: ".style(CACHE_MISS_STYLE))?; - - match miss { - CacheMiss::NotFound => { - writeln!( - f, - "{}", - "no previous cache entry found".style(CACHE_MISS_STYLE) - )?; - } - CacheMiss::FingerprintMismatch(mismatch) => { - match mismatch { - FingerprintMismatch::CommandFingerprintMismatch( - previous_command_fingerprint, - ) => { - let current_command_fingerprint = &status - .pre_execution_status - .task - .resolved_command - .fingerprint; - // Read diff fields directly - let mut changes = Vec::new(); - - // Check cwd changes - if previous_command_fingerprint.cwd - != current_command_fingerprint.cwd - { - const fn display_cwd(cwd: &RelativePath) -> &str { - if cwd.as_str().is_empty() { "." } else { cwd.as_str() } - } - changes.push(format!( - "working directory changed from '{}' to '{}'", - display_cwd(&previous_command_fingerprint.cwd), - display_cwd(¤t_command_fingerprint.cwd) - )); - } - - if previous_command_fingerprint.command - != current_command_fingerprint.command - { - changes.push(format!( - "command changed from {} to {}", - &previous_command_fingerprint.command, - ¤t_command_fingerprint.command - )); - } - - if previous_command_fingerprint.pass_through_envs - != current_command_fingerprint.pass_through_envs - { - changes.push(format!( - "pass-through env configuration changed from [{:?}] to [{:?}]", - previous_command_fingerprint.pass_through_envs.iter().join(", "), - current_command_fingerprint.pass_through_envs.iter().join(", ") - )); - } - - let mut previous_envs = previous_command_fingerprint - .envs_without_pass_through - .clone(); - let current_envs = - ¤t_command_fingerprint.envs_without_pass_through; - - for (key, current_value) in current_envs { - if let Some(previous_env_value) = previous_envs.remove(key) - { - if &previous_env_value != current_value { - changes.push(format!( - "env {key} value changed from '{previous_env_value}' to '{current_value}'", - )); - } - } else { - changes - .push(format!("env {key}={current_value} added",)); - } - } - for (key, previous_value) in previous_envs { - changes.push(format!("env {key}={previous_value} removed")); - } - - if changes.is_empty() { - writeln!( - f, - "{}", - "configuration changed".style(CACHE_MISS_STYLE) - )?; - } else { - writeln!( - f, - "{}", - changes.join("; ").style(CACHE_MISS_STYLE) - )?; - } - } - FingerprintMismatch::PostRunFingerprintMismatch( - PostRunFingerprintMismatch::InputContentChanged { path }, - ) => { - writeln!( - f, - "{}", - format!("content of input '{path}' changed") - .style(CACHE_MISS_STYLE) - )?; - } - } - } - } - } - } - - // Add spacing between tasks except for the last one - if idx < self.execution_statuses.len() - 1 { - writeln!( - f, - " {}", - "·······················································" - .style(Style::new().bright_black()) - )?; - } - } - - writeln!( - f, - "{}", - "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".style(Style::new().bright_black()) - )?; - - Ok(()) - } -} diff --git a/crates/vite_task/tests/fixtures/transitive-dependency-workspace/cli-queries.toml b/crates/vite_task/tests/fixtures/transitive-dependency-workspace/cli-queries.toml deleted file mode 100644 index 41491249..00000000 --- a/crates/vite_task/tests/fixtures/transitive-dependency-workspace/cli-queries.toml +++ /dev/null @@ -1,54 +0,0 @@ -[[query]] -name = "simple task by name" -cwd = "packages/a" -args = ["build"] - -[[query]] -name = "under subfolder of package" -cwd = "packages/a/src" -args = ["build"] - -[[query]] -name = "explicit package name under different package" -cwd = "packages/a" -args = ["@test/c#build"] - -[[query]] -name = "explicit package name under non-package cwd" -cwd = "" -args = ["@test/c#build"] - -[[query]] -name = "ambiguous task name" -cwd = "" -args = ["@test/a#build"] - -[[query]] -name = "ignore depends on" -cwd = "packages/a" -args = ["--ignore-depends-on", "build"] - -[[query]] -name = "transitive" -cwd = "packages/a" -args = ["--transitive", "build"] - -[[query]] -name = "transitive in package without the task" -cwd = "packages/a" -args = ["--transitive", "lint"] - -[[query]] -name = "transitive non existent task" -cwd = "packages/a" -args = ["--transitive", "non-existent-task"] - -[[query]] -name = "recursive" -cwd = "" -args = ["--recursive", "build"] - -[[query]] -name = "recursive and transitive" -cwd = "" -args = ["--recursive", "--transitive", "build"] diff --git a/crates/vite_task/tests/snapshots.rs b/crates/vite_task/tests/snapshots.rs deleted file mode 100644 index e003ca7d..00000000 --- a/crates/vite_task/tests/snapshots.rs +++ /dev/null @@ -1,189 +0,0 @@ -use core::panic; -use std::{path::Path, sync::Arc}; - -use clap::Parser; -use copy_dir::copy_dir; -use petgraph::visit::EdgeRef as _; -use tokio::runtime::Runtime; -use vite_path::{AbsolutePath, RelativePathBuf, redaction::redact_absolute_paths}; -use vite_str::Str; -use vite_task_graph::{ - IndexedTaskGraph, TaskDependencyType, TaskNodeIndex, - loader::JsonUserConfigLoader, - query::{TaskExecutionGraph, cli::CLITaskQuery}, -}; -use vite_workspace::find_workspace_root; - -#[derive(serde::Serialize, PartialEq, PartialOrd, Eq, Ord)] -struct TaskIdSnapshot { - package_dir: Arc, - task_name: Str, -} -impl TaskIdSnapshot { - fn new(task_index: TaskNodeIndex, indexed_task_graph: &IndexedTaskGraph) -> Self { - let task_id = &indexed_task_graph.task_graph()[task_index].task_id; - Self { - task_name: task_id.task_name.clone(), - package_dir: Arc::clone(&indexed_task_graph.get_package_path(task_id.package_index)), - } - } -} - -/// Create a stable json representation of the task graph for snapshot testing. -/// -/// All paths are relative to `base_dir`. -fn snapshot_task_graph(indexed_task_graph: &IndexedTaskGraph) -> impl serde::Serialize { - #[derive(serde::Serialize)] - struct TaskNodeSnapshot { - id: TaskIdSnapshot, - command: Str, - cwd: Arc, - depends_on: Vec<(TaskIdSnapshot, TaskDependencyType)>, - } - - let task_graph = indexed_task_graph.task_graph(); - let mut node_snapshots = Vec::::with_capacity(task_graph.node_count()); - for task_index in task_graph.node_indices() { - let task_node = &task_graph[task_index]; - let mut depends_on: Vec<(TaskIdSnapshot, TaskDependencyType)> = task_graph - .edges_directed(task_index, petgraph::Direction::Outgoing) - .map(|edge| (TaskIdSnapshot::new(edge.target(), indexed_task_graph), *edge.weight())) - .collect(); - depends_on.sort_unstable_by(|a, b| a.0.cmp(&b.0)); - node_snapshots.push(TaskNodeSnapshot { - id: TaskIdSnapshot::new(task_index, indexed_task_graph), - command: task_node.resolved_config.command.clone(), - cwd: Arc::clone(&task_node.resolved_config.resolved_options.cwd), - depends_on, - }); - } - node_snapshots.sort_unstable_by(|a, b| a.id.cmp(&b.id)); - - node_snapshots -} - -/// Create a stable json representation of the task graph for snapshot testing. -/// -/// All paths are relative to `base_dir`. -fn snapshot_execution_graph( - execution_graph: &TaskExecutionGraph, - indexed_task_graph: &IndexedTaskGraph, -) -> impl serde::Serialize { - #[derive(serde::Serialize, PartialEq)] - struct ExecutionNodeSnapshot { - task: TaskIdSnapshot, - deps: Vec, - } - - let mut execution_node_snapshots = Vec::::new(); - for task_index in execution_graph.nodes() { - let mut deps = execution_graph - .neighbors(task_index) - .map(|dep_index| TaskIdSnapshot::new(dep_index, indexed_task_graph)) - .collect::>(); - deps.sort_unstable(); - - execution_node_snapshots.push(ExecutionNodeSnapshot { - task: TaskIdSnapshot::new(task_index, indexed_task_graph), - deps, - }); - } - execution_node_snapshots.sort_unstable_by(|a, b| a.task.cmp(&b.task)); - execution_node_snapshots -} - -#[derive(serde::Deserialize)] -struct CLIQuery { - pub name: Str, - pub args: Vec, - pub cwd: RelativePathBuf, -} - -#[derive(serde::Deserialize, Default)] -struct CLIQueriesFile { - #[serde(rename = "query")] // toml usually uses singular for arrays - pub queries: Vec, -} - -fn run_case(runtime: &Runtime, tmpdir: &AbsolutePath, case_path: &Path) { - let case_name = case_path.file_name().unwrap().to_str().unwrap(); - if case_name.starts_with(".") { - return; // skip hidden files like .DS_Store - } - - // Copy the case directory to a temporary directory to avoid discovering workspace outside of the test case. - let case_stage_path = tmpdir.join(case_name); - copy_dir(case_path, &case_stage_path).unwrap(); - - let (workspace_root, _cwd) = find_workspace_root(&case_stage_path).unwrap(); - - assert_eq!( - &case_stage_path, &*workspace_root.path, - "folder '{}' should be a workspace root", - case_name - ); - - let cli_queries_toml_path = case_path.join("cli-queries.toml"); - let cli_queries_file: CLIQueriesFile = match std::fs::read(&cli_queries_toml_path) { - Ok(content) => toml::from_slice(&content).unwrap(), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Default::default(), - Err(err) => panic!("Failed to read cli-queries.toml for case {}: {}", case_name, err), - }; - - runtime.block_on(async { - let _redaction_guard = redact_absolute_paths(&workspace_root.path); - - let indexed_task_graph = vite_task_graph::IndexedTaskGraph::load( - &workspace_root, - &JsonUserConfigLoader::default(), - ) - .await - .expect(&format!("Failed to load task graph for case {case_name}")); - - let task_graph_snapshot = snapshot_task_graph(&indexed_task_graph); - insta::assert_json_snapshot!("task graph", task_graph_snapshot); - - for cli_query in cli_queries_file.queries { - let snapshot_name = format!("query - {}", cli_query.name); - - let cli_task_query = CLITaskQuery::try_parse_from( - std::iter::once("vite-run") // dummy program name - .chain(cli_query.args.iter().map(|s| s.as_str())), - ) - .expect(&format!( - "Failed to parse CLI args for query '{}' in case '{}'", - cli_query.name, case_name - )); - - let cwd: Arc = case_stage_path.join(&cli_query.cwd).into(); - let task_query = match cli_task_query.into_task_query(&cwd) { - Ok(ok) => ok, - Err(err) => { - insta::assert_json_snapshot!(snapshot_name, err); - continue; - } - }; - - let execution_graph = match indexed_task_graph.query_tasks(task_query) { - Ok(ok) => ok, - Err(err) => { - insta::assert_json_snapshot!(snapshot_name, err); - continue; - } - }; - - let execution_graph_snapshot = - snapshot_execution_graph(&execution_graph, &indexed_task_graph); - insta::assert_json_snapshot!(snapshot_name, execution_graph_snapshot); - } - }); -} - -#[test] -fn test_snapshots() { - let tokio_runtime = Runtime::new().unwrap(); - let tmp_dir = tempfile::tempdir().unwrap(); - let tmp_dir_path = AbsolutePath::new(tmp_dir.path()).unwrap(); - - insta::glob!("fixtures/*", |case_path| run_case(&tokio_runtime, tmp_dir_path, case_path)); -} diff --git a/crates/vite_task/tests/snapshots/snapshots__query - ambiguous task name@transitive-dependency-workspace.snap b/crates/vite_task/tests/snapshots/snapshots__query - ambiguous task name@transitive-dependency-workspace.snap deleted file mode 100644 index eafd86a2..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__query - ambiguous task name@transitive-dependency-workspace.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: err -input_file: crates/vite_task/tests/fixtures/transitive-dependency-workspace ---- -{ - "SpecifierLookupError": { - "specifier": { - "package_name": "@test/a", - "task_name": "build" - }, - "lookup_error": { - "AmbiguousPackageName": { - "package_name": "@test/a", - "package_paths": [ - "packages/a", - "packages/another-a" - ] - } - } - } -} diff --git a/crates/vite_task/tests/snapshots/snapshots__query - explicit package name under different package@transitive-dependency-workspace.snap b/crates/vite_task/tests/snapshots/snapshots__query - explicit package name under different package@transitive-dependency-workspace.snap deleted file mode 100644 index 3ff92394..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__query - explicit package name under different package@transitive-dependency-workspace.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: execution_graph_snapshot -input_file: crates/vite_task/tests/fixtures/transitive-dependency-workspace ---- -[ - { - "task": { - "package_dir": "packages/c", - "task_name": "build" - }, - "deps": [] - } -] diff --git a/crates/vite_task/tests/snapshots/snapshots__query - explicit package name under non-package cwd@transitive-dependency-workspace.snap b/crates/vite_task/tests/snapshots/snapshots__query - explicit package name under non-package cwd@transitive-dependency-workspace.snap deleted file mode 100644 index 3ff92394..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__query - explicit package name under non-package cwd@transitive-dependency-workspace.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: execution_graph_snapshot -input_file: crates/vite_task/tests/fixtures/transitive-dependency-workspace ---- -[ - { - "task": { - "package_dir": "packages/c", - "task_name": "build" - }, - "deps": [] - } -] diff --git a/crates/vite_task/tests/snapshots/snapshots__query - ignore depends on@transitive-dependency-workspace.snap b/crates/vite_task/tests/snapshots/snapshots__query - ignore depends on@transitive-dependency-workspace.snap deleted file mode 100644 index 5a65e8f5..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__query - ignore depends on@transitive-dependency-workspace.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: execution_graph_snapshot -input_file: crates/vite_task/tests/fixtures/transitive-dependency-workspace ---- -[ - { - "task": { - "package_dir": "packages/a", - "task_name": "build" - }, - "deps": [] - } -] diff --git a/crates/vite_task/tests/snapshots/snapshots__query - recursive and transitive@transitive-dependency-workspace.snap b/crates/vite_task/tests/snapshots/snapshots__query - recursive and transitive@transitive-dependency-workspace.snap deleted file mode 100644 index c267178d..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__query - recursive and transitive@transitive-dependency-workspace.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: err -input_file: crates/vite_task/tests/fixtures/transitive-dependency-workspace ---- -"RecursiveTransitiveConflict" diff --git a/crates/vite_task/tests/snapshots/snapshots__query - recursive@transitive-dependency-workspace.snap b/crates/vite_task/tests/snapshots/snapshots__query - recursive@transitive-dependency-workspace.snap deleted file mode 100644 index fdc5c1a5..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__query - recursive@transitive-dependency-workspace.snap +++ /dev/null @@ -1,60 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: execution_graph_snapshot -input_file: crates/vite_task/tests/fixtures/transitive-dependency-workspace ---- -[ - { - "task": { - "package_dir": "packages/a", - "task_name": "build" - }, - "deps": [ - { - "package_dir": "packages/a", - "task_name": "test" - }, - { - "package_dir": "packages/b2", - "task_name": "build" - }, - { - "package_dir": "packages/c", - "task_name": "build" - } - ] - }, - { - "task": { - "package_dir": "packages/a", - "task_name": "test" - }, - "deps": [] - }, - { - "task": { - "package_dir": "packages/another-a", - "task_name": "build" - }, - "deps": [] - }, - { - "task": { - "package_dir": "packages/b2", - "task_name": "build" - }, - "deps": [ - { - "package_dir": "packages/c", - "task_name": "build" - } - ] - }, - { - "task": { - "package_dir": "packages/c", - "task_name": "build" - }, - "deps": [] - } -] diff --git a/crates/vite_task/tests/snapshots/snapshots__query - simple task by name@transitive-dependency-workspace.snap b/crates/vite_task/tests/snapshots/snapshots__query - simple task by name@transitive-dependency-workspace.snap deleted file mode 100644 index 41fc332c..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__query - simple task by name@transitive-dependency-workspace.snap +++ /dev/null @@ -1,26 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: execution_graph_snapshot -input_file: crates/vite_task/tests/fixtures/transitive-dependency-workspace ---- -[ - { - "task": { - "package_dir": "packages/a", - "task_name": "build" - }, - "deps": [ - { - "package_dir": "packages/a", - "task_name": "test" - } - ] - }, - { - "task": { - "package_dir": "packages/a", - "task_name": "test" - }, - "deps": [] - } -] diff --git a/crates/vite_task/tests/snapshots/snapshots__query - transitive in package without the task@transitive-dependency-workspace.snap b/crates/vite_task/tests/snapshots/snapshots__query - transitive in package without the task@transitive-dependency-workspace.snap deleted file mode 100644 index 95c858b1..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__query - transitive in package without the task@transitive-dependency-workspace.snap +++ /dev/null @@ -1,26 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: execution_graph_snapshot -input_file: crates/vite_task/tests/fixtures/transitive-dependency-workspace ---- -[ - { - "task": { - "package_dir": "packages/b1", - "task_name": "lint" - }, - "deps": [ - { - "package_dir": "packages/c", - "task_name": "lint" - } - ] - }, - { - "task": { - "package_dir": "packages/c", - "task_name": "lint" - }, - "deps": [] - } -] diff --git a/crates/vite_task/tests/snapshots/snapshots__query - transitive non existent task@transitive-dependency-workspace.snap b/crates/vite_task/tests/snapshots/snapshots__query - transitive non existent task@transitive-dependency-workspace.snap deleted file mode 100644 index 3e61cf81..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__query - transitive non existent task@transitive-dependency-workspace.snap +++ /dev/null @@ -1,19 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: err -input_file: crates/vite_task/tests/fixtures/transitive-dependency-workspace ---- -{ - "SpecifierLookupError": { - "specifier": { - "package_name": null, - "task_name": "non-existent-task" - }, - "lookup_error": { - "TaskNameNotFound": { - "package_name": "@test/a", - "task_name": "non-existent-task" - } - } - } -} diff --git a/crates/vite_task/tests/snapshots/snapshots__query - transitive@transitive-dependency-workspace.snap b/crates/vite_task/tests/snapshots/snapshots__query - transitive@transitive-dependency-workspace.snap deleted file mode 100644 index 826bebad..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__query - transitive@transitive-dependency-workspace.snap +++ /dev/null @@ -1,53 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: execution_graph_snapshot -input_file: crates/vite_task/tests/fixtures/transitive-dependency-workspace ---- -[ - { - "task": { - "package_dir": "packages/a", - "task_name": "build" - }, - "deps": [ - { - "package_dir": "packages/a", - "task_name": "test" - }, - { - "package_dir": "packages/b2", - "task_name": "build" - }, - { - "package_dir": "packages/c", - "task_name": "build" - } - ] - }, - { - "task": { - "package_dir": "packages/a", - "task_name": "test" - }, - "deps": [] - }, - { - "task": { - "package_dir": "packages/b2", - "task_name": "build" - }, - "deps": [ - { - "package_dir": "packages/c", - "task_name": "build" - } - ] - }, - { - "task": { - "package_dir": "packages/c", - "task_name": "build" - }, - "deps": [] - } -] diff --git a/crates/vite_task/tests/snapshots/snapshots__query - under subfolder of package@transitive-dependency-workspace.snap b/crates/vite_task/tests/snapshots/snapshots__query - under subfolder of package@transitive-dependency-workspace.snap deleted file mode 100644 index 41fc332c..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__query - under subfolder of package@transitive-dependency-workspace.snap +++ /dev/null @@ -1,26 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: execution_graph_snapshot -input_file: crates/vite_task/tests/fixtures/transitive-dependency-workspace ---- -[ - { - "task": { - "package_dir": "packages/a", - "task_name": "build" - }, - "deps": [ - { - "package_dir": "packages/a", - "task_name": "test" - } - ] - }, - { - "task": { - "package_dir": "packages/a", - "task_name": "test" - }, - "deps": [] - } -] diff --git a/crates/vite_task/tests/snapshots/snapshots__task graph@cache-sharing.snap b/crates/vite_task/tests/snapshots/snapshots__task graph@cache-sharing.snap deleted file mode 100644 index 2f23f391..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__task graph@cache-sharing.snap +++ /dev/null @@ -1,34 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: task_graph_snapshot -input_file: crates/vite_task/tests/fixtures/cache-sharing ---- -[ - { - "id": { - "package_dir": "", - "task_name": "a" - }, - "command": "echo a", - "cwd": "", - "depends_on": [] - }, - { - "id": { - "package_dir": "", - "task_name": "b" - }, - "command": "echo a && echo b", - "cwd": "", - "depends_on": [] - }, - { - "id": { - "package_dir": "", - "task_name": "c" - }, - "command": "echo a && echo b && echo c", - "cwd": "", - "depends_on": [] - } -] diff --git a/crates/vite_task/tests/snapshots/snapshots__task graph@comprehensive-task-graph.snap b/crates/vite_task/tests/snapshots/snapshots__task graph@comprehensive-task-graph.snap deleted file mode 100644 index d0e44840..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__task graph@comprehensive-task-graph.snap +++ /dev/null @@ -1,351 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: task_graph_snapshot -input_file: crates/vite_task/tests/fixtures/comprehensive-task-graph ---- -[ - { - "id": { - "package_dir": "packages/api", - "task_name": "build" - }, - "command": "echo Generate schemas && echo Compile TypeScript && echo Bundle API && echo Copy assets", - "cwd": "packages/api", - "depends_on": [ - [ - { - "package_dir": "packages/config", - "task_name": "build" - }, - "Topological" - ], - [ - { - "package_dir": "packages/shared", - "task_name": "build" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/api", - "task_name": "dev" - }, - "command": "echo Watch mode && echo Start dev server", - "cwd": "packages/api", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/api", - "task_name": "start" - }, - "command": "echo Starting API server", - "cwd": "packages/api", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/api", - "task_name": "test" - }, - "command": "echo Testing API", - "cwd": "packages/api", - "depends_on": [ - [ - { - "package_dir": "packages/shared", - "task_name": "test" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/app", - "task_name": "build" - }, - "command": "echo Clean dist && echo Build client && echo Build server && echo Generate manifest && echo Optimize assets", - "cwd": "packages/app", - "depends_on": [ - [ - { - "package_dir": "packages/api", - "task_name": "build" - }, - "Topological" - ], - [ - { - "package_dir": "packages/pkg#special", - "task_name": "build" - }, - "Topological" - ], - [ - { - "package_dir": "packages/shared", - "task_name": "build" - }, - "Topological" - ], - [ - { - "package_dir": "packages/ui", - "task_name": "build" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/app", - "task_name": "deploy" - }, - "command": "echo Validate && echo Upload && echo Verify", - "cwd": "packages/app", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/app", - "task_name": "dev" - }, - "command": "echo Running dev server", - "cwd": "packages/app", - "depends_on": [ - [ - { - "package_dir": "packages/api", - "task_name": "dev" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/app", - "task_name": "preview" - }, - "command": "echo Preview build", - "cwd": "packages/app", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/app", - "task_name": "test" - }, - "command": "echo Unit tests && echo Integration tests", - "cwd": "packages/app", - "depends_on": [ - [ - { - "package_dir": "packages/api", - "task_name": "test" - }, - "Topological" - ], - [ - { - "package_dir": "packages/pkg#special", - "task_name": "test" - }, - "Topological" - ], - [ - { - "package_dir": "packages/shared", - "task_name": "test" - }, - "Topological" - ], - [ - { - "package_dir": "packages/ui", - "task_name": "test" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/config", - "task_name": "build" - }, - "command": "echo Building config", - "cwd": "packages/config", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/config", - "task_name": "validate" - }, - "command": "echo Validating config", - "cwd": "packages/config", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/pkg#special", - "task_name": "build" - }, - "command": "echo Building package with hash", - "cwd": "packages/pkg#special", - "depends_on": [ - [ - { - "package_dir": "packages/shared", - "task_name": "build" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/pkg#special", - "task_name": "test" - }, - "command": "echo Testing package with hash", - "cwd": "packages/pkg#special", - "depends_on": [ - [ - { - "package_dir": "packages/shared", - "task_name": "test" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/shared", - "task_name": "build" - }, - "command": "echo Cleaning && echo Compiling shared && echo Generating types", - "cwd": "packages/shared", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/shared", - "task_name": "lint" - }, - "command": "echo Linting shared", - "cwd": "packages/shared", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/shared", - "task_name": "test" - }, - "command": "echo Setting up test env && echo Running tests && echo Cleanup", - "cwd": "packages/shared", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/shared", - "task_name": "typecheck" - }, - "command": "echo Type checking shared", - "cwd": "packages/shared", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/tools", - "task_name": "generate" - }, - "command": "echo Generating tools", - "cwd": "packages/tools", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/tools", - "task_name": "validate" - }, - "command": "echo Validating", - "cwd": "packages/tools", - "depends_on": [ - [ - { - "package_dir": "packages/config", - "task_name": "validate" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/ui", - "task_name": "build" - }, - "command": "echo Compile styles && echo Build components && echo Generate types", - "cwd": "packages/ui", - "depends_on": [ - [ - { - "package_dir": "packages/shared", - "task_name": "build" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/ui", - "task_name": "lint" - }, - "command": "echo Linting UI", - "cwd": "packages/ui", - "depends_on": [ - [ - { - "package_dir": "packages/shared", - "task_name": "lint" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/ui", - "task_name": "storybook" - }, - "command": "echo Running storybook", - "cwd": "packages/ui", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/ui", - "task_name": "test" - }, - "command": "echo Testing UI", - "cwd": "packages/ui", - "depends_on": [ - [ - { - "package_dir": "packages/shared", - "task_name": "test" - }, - "Topological" - ] - ] - } -] diff --git a/crates/vite_task/tests/snapshots/snapshots__task graph@conflict-test.snap b/crates/vite_task/tests/snapshots/snapshots__task graph@conflict-test.snap deleted file mode 100644 index 80e5603f..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__task graph@conflict-test.snap +++ /dev/null @@ -1,42 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: task_graph_snapshot -input_file: crates/vite_task/tests/fixtures/conflict-test ---- -[ - { - "id": { - "package_dir": "packages/scope-a", - "task_name": "b#c" - }, - "command": "echo Task b#c in scope-a", - "cwd": "packages/scope-a", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/scope-a-b", - "task_name": "c" - }, - "command": "echo Task c in scope-a#b", - "cwd": "packages/scope-a-b", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/test-package", - "task_name": "test" - }, - "command": "echo Testing", - "cwd": "packages/test-package", - "depends_on": [ - [ - { - "package_dir": "packages/scope-a-b", - "task_name": "c" - }, - "Explicit" - ] - ] - } -] diff --git a/crates/vite_task/tests/snapshots/snapshots__task graph@dependency-both-topo-and-explicit.snap b/crates/vite_task/tests/snapshots/snapshots__task graph@dependency-both-topo-and-explicit.snap deleted file mode 100644 index b609a8d2..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__task graph@dependency-both-topo-and-explicit.snap +++ /dev/null @@ -1,33 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: task_graph_snapshot -input_file: crates/vite_task/tests/fixtures/dependency-both-topo-and-explicit ---- -[ - { - "id": { - "package_dir": "packages/a", - "task_name": "build" - }, - "command": "build a", - "cwd": "packages/a", - "depends_on": [ - [ - { - "package_dir": "packages/b", - "task_name": "build" - }, - "Both" - ] - ] - }, - { - "id": { - "package_dir": "packages/b", - "task_name": "build" - }, - "command": "build b", - "cwd": "packages/b", - "depends_on": [] - } -] diff --git a/crates/vite_task/tests/snapshots/snapshots__task graph@empty-package-test.snap b/crates/vite_task/tests/snapshots/snapshots__task graph@empty-package-test.snap deleted file mode 100644 index 5508e88e..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__task graph@empty-package-test.snap +++ /dev/null @@ -1,141 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: task_graph_snapshot -input_file: crates/vite_task/tests/fixtures/empty-package-test ---- -[ - { - "id": { - "package_dir": "packages/another-empty", - "task_name": "build" - }, - "command": "echo 'Building another-empty package'", - "cwd": "packages/another-empty", - "depends_on": [ - [ - { - "package_dir": "packages/another-empty", - "task_name": "lint" - }, - "Explicit" - ], - [ - { - "package_dir": "packages/normal-package", - "task_name": "build" - }, - "Topological" - ], - [ - { - "package_dir": "packages/normal-package", - "task_name": "test" - }, - "Explicit" - ] - ] - }, - { - "id": { - "package_dir": "packages/another-empty", - "task_name": "deploy" - }, - "command": "echo 'Deploying another-empty package'", - "cwd": "packages/another-empty", - "depends_on": [ - [ - { - "package_dir": "packages/another-empty", - "task_name": "build" - }, - "Explicit" - ], - [ - { - "package_dir": "packages/another-empty", - "task_name": "test" - }, - "Explicit" - ] - ] - }, - { - "id": { - "package_dir": "packages/another-empty", - "task_name": "lint" - }, - "command": "echo 'Linting another-empty package'", - "cwd": "packages/another-empty", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/another-empty", - "task_name": "test" - }, - "command": "echo 'Testing another-empty package'", - "cwd": "packages/another-empty", - "depends_on": [ - [ - { - "package_dir": "packages/normal-package", - "task_name": "test" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/empty-name", - "task_name": "build" - }, - "command": "echo 'Building empty-name package'", - "cwd": "packages/empty-name", - "depends_on": [ - [ - { - "package_dir": "packages/empty-name", - "task_name": "test" - }, - "Explicit" - ] - ] - }, - { - "id": { - "package_dir": "packages/empty-name", - "task_name": "lint" - }, - "command": "echo 'Linting empty-name package'", - "cwd": "packages/empty-name", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/empty-name", - "task_name": "test" - }, - "command": "echo 'Testing empty-name package'", - "cwd": "packages/empty-name", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/normal-package", - "task_name": "build" - }, - "command": "echo 'Building normal-package'", - "cwd": "packages/normal-package", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/normal-package", - "task_name": "test" - }, - "command": "echo 'Testing normal-package'", - "cwd": "packages/normal-package", - "depends_on": [] - } -] diff --git a/crates/vite_task/tests/snapshots/snapshots__task graph@explicit-deps-workspace.snap b/crates/vite_task/tests/snapshots/snapshots__task graph@explicit-deps-workspace.snap deleted file mode 100644 index f2e12dd0..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__task graph@explicit-deps-workspace.snap +++ /dev/null @@ -1,190 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: task_graph_snapshot -input_file: crates/vite_task/tests/fixtures/explicit-deps-workspace ---- -[ - { - "id": { - "package_dir": "packages/app", - "task_name": "build" - }, - "command": "echo 'Building @test/app'", - "cwd": "packages/app", - "depends_on": [ - [ - { - "package_dir": "packages/utils", - "task_name": "build" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/app", - "task_name": "deploy" - }, - "command": "deploy-script --prod", - "cwd": "packages/app", - "depends_on": [ - [ - { - "package_dir": "packages/app", - "task_name": "build" - }, - "Explicit" - ], - [ - { - "package_dir": "packages/app", - "task_name": "test" - }, - "Explicit" - ], - [ - { - "package_dir": "packages/utils", - "task_name": "lint" - }, - "Explicit" - ] - ] - }, - { - "id": { - "package_dir": "packages/app", - "task_name": "start" - }, - "command": "echo 'Starting @test/app'", - "cwd": "packages/app", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/app", - "task_name": "test" - }, - "command": "echo 'Testing @test/app'", - "cwd": "packages/app", - "depends_on": [ - [ - { - "package_dir": "packages/utils", - "task_name": "test" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/core", - "task_name": "build" - }, - "command": "echo 'Building @test/core'", - "cwd": "packages/core", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/core", - "task_name": "clean" - }, - "command": "echo 'Cleaning @test/core'", - "cwd": "packages/core", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/core", - "task_name": "lint" - }, - "command": "eslint src", - "cwd": "packages/core", - "depends_on": [ - [ - { - "package_dir": "packages/core", - "task_name": "clean" - }, - "Explicit" - ] - ] - }, - { - "id": { - "package_dir": "packages/core", - "task_name": "test" - }, - "command": "echo 'Testing @test/core'", - "cwd": "packages/core", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/utils", - "task_name": "build" - }, - "command": "echo 'Building @test/utils'", - "cwd": "packages/utils", - "depends_on": [ - [ - { - "package_dir": "packages/core", - "task_name": "build" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/utils", - "task_name": "lint" - }, - "command": "eslint src", - "cwd": "packages/utils", - "depends_on": [ - [ - { - "package_dir": "packages/core", - "task_name": "build" - }, - "Explicit" - ], - [ - { - "package_dir": "packages/core", - "task_name": "lint" - }, - "Topological" - ], - [ - { - "package_dir": "packages/utils", - "task_name": "build" - }, - "Explicit" - ] - ] - }, - { - "id": { - "package_dir": "packages/utils", - "task_name": "test" - }, - "command": "echo 'Testing @test/utils'", - "cwd": "packages/utils", - "depends_on": [ - [ - { - "package_dir": "packages/core", - "task_name": "test" - }, - "Topological" - ] - ] - } -] diff --git a/crates/vite_task/tests/snapshots/snapshots__task graph@fingerprint-ignore-test.snap b/crates/vite_task/tests/snapshots/snapshots__task graph@fingerprint-ignore-test.snap deleted file mode 100644 index 67c9f908..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__task graph@fingerprint-ignore-test.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: task_graph_snapshot -input_file: crates/vite_task/tests/fixtures/fingerprint-ignore-test ---- -[ - { - "id": { - "package_dir": "", - "task_name": "create-files" - }, - "command": "mkdir -p node_modules/pkg-a && echo '{\"name\":\"pkg-a\"}' > node_modules/pkg-a/package.json && echo 'content' > node_modules/pkg-a/index.js && mkdir -p dist && echo 'output' > dist/bundle.js", - "cwd": "", - "depends_on": [] - } -] diff --git a/crates/vite_task/tests/snapshots/snapshots__task graph@recursive-topological-workspace.snap b/crates/vite_task/tests/snapshots/snapshots__task graph@recursive-topological-workspace.snap deleted file mode 100644 index fef43080..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__task graph@recursive-topological-workspace.snap +++ /dev/null @@ -1,126 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: task_graph_snapshot -input_file: crates/vite_task/tests/fixtures/recursive-topological-workspace ---- -[ - { - "id": { - "package_dir": "apps/web", - "task_name": "build" - }, - "command": "echo 'Building @test/web'", - "cwd": "apps/web", - "depends_on": [ - [ - { - "package_dir": "packages/app", - "task_name": "build" - }, - "Topological" - ], - [ - { - "package_dir": "packages/core", - "task_name": "build" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "apps/web", - "task_name": "dev" - }, - "command": "echo 'Running @test/web in dev mode'", - "cwd": "apps/web", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/app", - "task_name": "build" - }, - "command": "echo 'Building @test/app'", - "cwd": "packages/app", - "depends_on": [ - [ - { - "package_dir": "packages/utils", - "task_name": "build" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/app", - "task_name": "test" - }, - "command": "echo 'Testing @test/app'", - "cwd": "packages/app", - "depends_on": [ - [ - { - "package_dir": "packages/utils", - "task_name": "test" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/core", - "task_name": "build" - }, - "command": "echo 'Building @test/core'", - "cwd": "packages/core", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/core", - "task_name": "test" - }, - "command": "echo 'Testing @test/core'", - "cwd": "packages/core", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/utils", - "task_name": "build" - }, - "command": "echo 'Preparing @test/utils' && echo 'Building @test/utils' && echo 'Done @test/utils'", - "cwd": "packages/utils", - "depends_on": [ - [ - { - "package_dir": "packages/core", - "task_name": "build" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/utils", - "task_name": "test" - }, - "command": "echo 'Testing @test/utils'", - "cwd": "packages/utils", - "depends_on": [ - [ - { - "package_dir": "packages/core", - "task_name": "test" - }, - "Topological" - ] - ] - } -] diff --git a/crates/vite_task/tests/snapshots/snapshots__task graph@transitive-dependency-workspace.snap b/crates/vite_task/tests/snapshots/snapshots__task graph@transitive-dependency-workspace.snap deleted file mode 100644 index 98b23222..00000000 --- a/crates/vite_task/tests/snapshots/snapshots__task graph@transitive-dependency-workspace.snap +++ /dev/null @@ -1,108 +0,0 @@ ---- -source: crates/vite_task/tests/snapshots.rs -expression: task_graph_snapshot -input_file: crates/vite_task/tests/fixtures/transitive-dependency-workspace ---- -[ - { - "id": { - "package_dir": "packages/a", - "task_name": "build" - }, - "command": "echo Building A", - "cwd": "packages/a", - "depends_on": [ - [ - { - "package_dir": "packages/a", - "task_name": "test" - }, - "Explicit" - ], - [ - { - "package_dir": "packages/b2", - "task_name": "build" - }, - "Topological" - ], - [ - { - "package_dir": "packages/c", - "task_name": "build" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/a", - "task_name": "test" - }, - "command": "echo test a", - "cwd": "packages/a", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/another-a", - "task_name": "build" - }, - "command": "echo Building another A", - "cwd": "packages/another-a", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/b1", - "task_name": "lint" - }, - "command": "echo lint b1", - "cwd": "packages/b1", - "depends_on": [ - [ - { - "package_dir": "packages/c", - "task_name": "lint" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/b2", - "task_name": "build" - }, - "command": "echo build b2", - "cwd": "packages/b2", - "depends_on": [ - [ - { - "package_dir": "packages/c", - "task_name": "build" - }, - "Topological" - ] - ] - }, - { - "id": { - "package_dir": "packages/c", - "task_name": "build" - }, - "command": "echo Building C", - "cwd": "packages/c", - "depends_on": [] - }, - { - "id": { - "package_dir": "packages/c", - "task_name": "lint" - }, - "command": "echo lint c", - "cwd": "packages/c", - "depends_on": [] - } -] diff --git a/crates/vite_task_bin/Cargo.toml b/crates/vite_task_bin/Cargo.toml new file mode 100644 index 00000000..6a377b2a --- /dev/null +++ b/crates/vite_task_bin/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "vite_task_bin" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[[bin]] +name = "vite" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +clap = { workspace = true, features = ["derive"] } +tokio = { workspace = true, features = ["full"] } +vite_path = { workspace = true } +vite_str = { workspace = true } +vite_task = { workspace = true } +which = { workspace = true } + +[dev-dependencies] +copy_dir = { workspace = true } +cow-utils = { workspace = true } +insta = { workspace = true, features = ["glob", "json", "redactions", "filters", "ron"] } +petgraph = { workspace = true } +regex = { workspace = true } +serde = { workspace = true, features = ["derive", "rc"] } +serde_json = { workspace = true } +tempfile = { workspace = true } +toml = { workspace = true } +vite_graph_ser = { workspace = true } +vite_path = { workspace = true, features = ["absolute-redaction"] } +vite_task_graph = { workspace = true } +vite_workspace = { workspace = true } + +[lints] +workspace = true diff --git a/crates/vite_task_bin/src/lib.rs b/crates/vite_task_bin/src/lib.rs new file mode 100644 index 00000000..ec51c189 --- /dev/null +++ b/crates/vite_task_bin/src/lib.rs @@ -0,0 +1,94 @@ +use std::{ + env::{self, join_paths}, + ffi::OsStr, + iter, + path::PathBuf, + sync::Arc, +}; + +use clap::Subcommand; +use vite_path::{AbsolutePath, current_dir}; +use vite_str::Str; +use vite_task::{CLIArgs, Session, SessionCallbacks, plan_request::SyntheticPlanRequest}; + +/// Theses are the custom subcommands that synthesize tasks for vite-task +#[derive(Debug, Subcommand)] +pub enum CustomTaskSubcommand { + /// oxlint + Lint { + #[clap(allow_hyphen_values = true, trailing_var_arg = true)] + args: Vec, + }, +} + +// These are the subcommands that is not handled by vite-task +#[derive(Debug, Subcommand)] +pub enum NonTaskSubcommand { + Version, +} + +#[derive(Debug, Default)] +pub struct TaskSynthesizer(()); + +fn find_executable( + path_env: Option<&Arc>, + cwd: &AbsolutePath, + executable: &str, +) -> anyhow::Result> { + let mut paths: Vec = + if let Some(path_env) = path_env { env::split_paths(path_env).collect() } else { vec![] }; + let mut current_cwd_parent = cwd; + loop { + let node_modules_bin = current_cwd_parent.join("node_modules").join(".bin"); + paths.push(node_modules_bin.as_path().to_path_buf()); + if let Some(parent) = current_cwd_parent.parent() { + current_cwd_parent = parent; + } else { + break; + } + } + let executable_path = which::which_in(executable, Some(join_paths(paths)?), cwd)?; + Ok(executable_path.into_os_string().into()) +} + +#[async_trait::async_trait(?Send)] +impl vite_task::TaskSynthesizer for TaskSynthesizer { + fn should_synthesize_for_program(&self, program: &str) -> bool { + program == "vite" + } + + async fn synthesize_task( + &mut self, + subcommand: CustomTaskSubcommand, + path_env: Option<&Arc>, + cwd: &Arc, + ) -> anyhow::Result { + match subcommand { + CustomTaskSubcommand::Lint { args } => { + let direct_execution_cache_key: Arc<[Str]> = + iter::once(Str::from("lint")).chain(args.iter().cloned()).collect(); + Ok(SyntheticPlanRequest { + program: find_executable(path_env, &*cwd, "oxlint")?, + args: args.into(), + task_options: Default::default(), + direct_execution_cache_key, + }) + } + } + } +} + +#[derive(Default)] +pub struct OwnedSessionCallbacks { + task_synthesizer: TaskSynthesizer, + user_config_loader: vite_task::loader::JsonUserConfigLoader, +} + +impl OwnedSessionCallbacks { + pub fn as_callbacks(&mut self) -> SessionCallbacks<'_, CustomTaskSubcommand> { + SessionCallbacks { + task_synthesizer: &mut self.task_synthesizer, + user_config_loader: &mut self.user_config_loader, + } + } +} diff --git a/crates/vite_task_bin/src/main.rs b/crates/vite_task_bin/src/main.rs new file mode 100644 index 00000000..3f521a7f --- /dev/null +++ b/crates/vite_task_bin/src/main.rs @@ -0,0 +1,38 @@ +use std::{env, sync::Arc}; + +use vite_path::{AbsolutePath, current_dir}; +use vite_task::{CLIArgs, Session, SessionCallbacks, session::reporter::LabeledReporter}; +use vite_task_bin::{ + CustomTaskSubcommand, NonTaskSubcommand, OwnedSessionCallbacks, TaskSynthesizer, +}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cwd: Arc = current_dir()?.into(); + // Parse the CLI arguments and see if they are for vite-task or not + let args = match CLIArgs::::try_parse_from(env::args()) + { + Ok(ok) => ok, + Err(err) => { + err.exit(); + } + }; + let task_cli_args = match args { + CLIArgs::Task(task_cli_args) => task_cli_args, + CLIArgs::NonTask(NonTaskSubcommand::Version) => { + // Non-task subcommands are not handled by vite-task's session. + println!("{}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + }; + + let mut owned_callbacks = OwnedSessionCallbacks::default(); + let mut session = Session::init(owned_callbacks.as_callbacks())?; + let plan = session.plan_from_cli(cwd, task_cli_args).await?; + + // Create reporter and execute + let reporter = LabeledReporter::new(std::io::stdout(), session.workspace_path()); + session.execute(plan, Box::new(reporter)).await?; + + Ok(()) +} diff --git a/crates/vite_task_bin/test_bins/README.md b/crates/vite_task_bin/test_bins/README.md new file mode 100644 index 00000000..40f338fd --- /dev/null +++ b/crates/vite_task_bin/test_bins/README.md @@ -0,0 +1 @@ +This package contains test binaries used in the tests for vite_task_bin crate. diff --git a/crates/vite_task_bin/test_bins/package.json b/crates/vite_task_bin/test_bins/package.json new file mode 100644 index 00000000..731e82fa --- /dev/null +++ b/crates/vite_task_bin/test_bins/package.json @@ -0,0 +1,14 @@ +{ + "name": "vite-task-test-bins", + "type": "module", + "private": true, + "bin": { + "print-file": "./src/print-file.ts", + "json-edit": "./src/json-edit.ts", + "replace-file-content": "./src/replace-file-content.ts" + }, + "dependencies": { + "oxlint": "catalog:", + "vite-task-test-bins": "link:" + } +} diff --git a/crates/vite_task_bin/test_bins/src/json-edit.ts b/crates/vite_task_bin/test_bins/src/json-edit.ts new file mode 100755 index 00000000..d9593f21 --- /dev/null +++ b/crates/vite_task_bin/test_bins/src/json-edit.ts @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync } from 'node:fs'; +import { parseArgs } from 'node:util'; + +const { positionals } = parseArgs({ + allowPositionals: true, +}); + +const filename = positionals[0]; +const script = positionals[1]; + +if (!filename || !script) { + console.error('Usage: json-edit