Skip to content

Commit a7b0d0a

Browse files
branchseerclaude
andauthored
feat: add --log=interleaved|labeled|grouped modes (#266)
## Summary - Add `--log` flag with three output modes: `interleaved` (default), `labeled`, `grouped` - Refactor reporter system into three mode reporters + a `SummaryReporter` decorator - Remove `all_ancestors_single_node` graph topology tracking — stdio inheritance is now mode-based - Add E2E snapshot tests covering all stdio cases for each mode ### `--log=interleaved` (default) Streams output directly. Uncached tasks inherit stdio (TTY), cached tasks pipe for capture/replay. ### `--log=labeled` Prefixes each output line with `[pkg#task]`. Always piped. ### `--log=grouped` Buffers all output per task and prints as a block on completion. Always piped. ## Test plan - [x] 27 unit tests (reporters, writers) - [x] E2E snapshot tests: `interleaved-stdio` (8 cases), `labeled-stdio` (7 cases), `grouped-stdio` (7 cases) - [ ] Cross-platform clippy (`just lint-linux`, `just lint-windows`) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f385930 commit a7b0d0a

64 files changed

Lines changed: 1464 additions & 611 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

crates/vite_task/src/cli/mod.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ use vite_task_graph::{TaskSpecifier, query::TaskQuery};
77
use vite_task_plan::plan_request::{CacheOverride, PlanOptions, QueryPlanRequest};
88
use vite_workspace::package_filter::{PackageQueryArgs, PackageQueryError};
99

10+
/// Controls how task output is displayed.
11+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
12+
pub enum LogMode {
13+
/// Output streams directly to the terminal as tasks produce it.
14+
#[default]
15+
Interleaved,
16+
/// Each line is prefixed with `[packageName#taskName]`.
17+
Labeled,
18+
/// Output is buffered per task and printed as a block after each task completes.
19+
Grouped,
20+
}
21+
1022
#[derive(Debug, Clone, clap::Subcommand)]
1123
pub enum CacheSubcommand {
1224
/// Clean up all the cache
@@ -35,6 +47,10 @@ pub struct RunFlags {
3547
/// Force caching off for all tasks and scripts.
3648
#[clap(long, conflicts_with = "cache")]
3749
pub no_cache: bool,
50+
51+
/// How task output is displayed.
52+
#[clap(long, default_value = "interleaved")]
53+
pub log: LogMode,
3854
}
3955

4056
impl RunFlags {

crates/vite_task/src/session/cache/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ pub struct ExecutionCache {
7373

7474
const BINCODE_CONFIG: bincode::config::Configuration = bincode::config::standard();
7575

76-
#[derive(Debug, Serialize, Deserialize)]
76+
#[derive(Debug, Clone, Serialize, Deserialize)]
7777
#[expect(
7878
clippy::large_enum_variant,
7979
reason = "FingerprintMismatch contains SpawnFingerprint which is intentionally large; boxing would add unnecessary indirection for a short-lived enum"
@@ -93,7 +93,7 @@ pub enum InputChangeKind {
9393
Removed,
9494
}
9595

96-
#[derive(Debug, Serialize, Deserialize)]
96+
#[derive(Debug, Clone, Serialize, Deserialize)]
9797
pub enum FingerprintMismatch {
9898
/// Found a previous cache entry key for the same task, but the spawn fingerprint differs.
9999
/// This happens when the command itself or an env changes.

crates/vite_task/src/session/event.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ pub enum ExecutionError {
4545
PostRunFingerprint(#[source] anyhow::Error),
4646
}
4747

48-
#[derive(Debug)]
48+
#[derive(Debug, Clone)]
4949
pub enum CacheDisabledReason {
5050
InProcessExecution,
5151
NoCacheMetadata,
@@ -77,7 +77,7 @@ pub enum CacheUpdateStatus {
7777
NotUpdated(CacheNotUpdatedReason),
7878
}
7979

80-
#[derive(Debug)]
80+
#[derive(Debug, Clone)]
8181
#[expect(
8282
clippy::large_enum_variant,
8383
reason = "CacheMiss variant is intentionally large and infrequently cloned"

crates/vite_task/src/session/execute/mod.rs

Lines changed: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -66,22 +66,13 @@ impl ExecutionContext<'_> {
6666
/// We compute a topological order and iterate in reverse to get execution order
6767
/// (dependencies before dependents).
6868
///
69-
/// `all_ancestors_single_node` tracks whether every graph in the ancestry chain
70-
/// (from the root down to this level) contains exactly one node. The initial call
71-
/// passes `graph.node_count() == 1`; recursive calls AND with the nested graph's
72-
/// node count.
73-
///
7469
/// Fast-fail: if any task fails (non-zero exit or infrastructure error), remaining
7570
/// tasks and `&&`-chained items are skipped. Leaf-level errors are reported through
7671
/// the reporter. Cycle detection is handled at plan time.
7772
///
7873
/// Returns `true` if all tasks succeeded, `false` if any task failed.
7974
#[tracing::instrument(level = "debug", skip_all)]
80-
async fn execute_expanded_graph(
81-
&mut self,
82-
graph: &ExecutionGraph,
83-
all_ancestors_single_node: bool,
84-
) -> bool {
75+
async fn execute_expanded_graph(&mut self, graph: &ExecutionGraph) -> bool {
8576
// `compute_topological_order()` returns nodes in topological order: for every
8677
// edge A→B, A appears before B. Since our edges mean "A depends on B",
8778
// dependencies (B) appear after their dependents (A). We iterate in reverse
@@ -97,23 +88,13 @@ impl ExecutionContext<'_> {
9788
for item in &task_execution.items {
9889
let failed = match &item.kind {
9990
ExecutionItemKind::Leaf(leaf_kind) => {
100-
self.execute_leaf(
101-
&item.execution_item_display,
102-
leaf_kind,
103-
all_ancestors_single_node,
104-
)
105-
.boxed_local()
106-
.await
107-
}
108-
ExecutionItemKind::Expanded(nested_graph) => {
109-
!self
110-
.execute_expanded_graph(
111-
nested_graph,
112-
all_ancestors_single_node && nested_graph.node_count() == 1,
113-
)
91+
self.execute_leaf(&item.execution_item_display, leaf_kind)
11492
.boxed_local()
11593
.await
11694
}
95+
ExecutionItemKind::Expanded(nested_graph) => {
96+
!self.execute_expanded_graph(nested_graph).boxed_local().await
97+
}
11798
};
11899
if failed {
119100
return false;
@@ -134,10 +115,8 @@ impl ExecutionContext<'_> {
134115
&mut self,
135116
display: &ExecutionItemDisplay,
136117
leaf_kind: &LeafExecutionKind,
137-
all_ancestors_single_node: bool,
138118
) -> bool {
139-
let mut leaf_reporter =
140-
self.reporter.new_leaf_execution(display, leaf_kind, all_ancestors_single_node);
119+
let mut leaf_reporter = self.reporter.new_leaf_execution(display, leaf_kind);
141120

142121
match leaf_kind {
143122
LeafExecutionKind::InProcess(in_process_execution) => {
@@ -543,8 +522,7 @@ impl Session<'_> {
543522

544523
// Execute the graph with fast-fail: if any task fails, remaining tasks
545524
// are skipped. Leaf-level errors are reported through the reporter.
546-
let all_single_node = execution_graph.node_count() == 1;
547-
execution_context.execute_expanded_graph(&execution_graph, all_single_node).await;
525+
execution_context.execute_expanded_graph(&execution_graph).await;
548526

549527
// Leaf-level errors and non-zero exit statuses are tracked internally
550528
// by the reporter.

crates/vite_task/src/session/mod.rs

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ use clap::Parser as _;
1212
use once_cell::sync::OnceCell;
1313
pub use reporter::ExitStatus;
1414
use reporter::{
15-
LabeledReporterBuilder,
15+
GroupedReporterBuilder, InterleavedReporterBuilder, LabeledReporterBuilder,
16+
SummaryReporterBuilder,
1617
summary::{LastRunSummary, ReadSummaryError, format_full_summary},
1718
};
1819
use rustc_hash::FxHashMap;
@@ -300,14 +301,33 @@ impl<'a> Session<'a> {
300301
self.plan_from_query(qpr).await?
301302
};
302303

303-
let builder = LabeledReporterBuilder::new(
304-
self.workspace_path(),
304+
let workspace_path = self.workspace_path();
305+
let writer: Box<dyn std::io::Write> = Box::new(std::io::stdout());
306+
307+
let inner: Box<dyn reporter::GraphExecutionReporterBuilder> =
308+
match run_command.flags.log {
309+
crate::cli::LogMode::Interleaved => Box::new(
310+
InterleavedReporterBuilder::new(Arc::clone(&workspace_path), writer),
311+
),
312+
crate::cli::LogMode::Labeled => Box::new(LabeledReporterBuilder::new(
313+
Arc::clone(&workspace_path),
314+
writer,
315+
)),
316+
crate::cli::LogMode::Grouped => Box::new(GroupedReporterBuilder::new(
317+
Arc::clone(&workspace_path),
318+
writer,
319+
)),
320+
};
321+
322+
let builder = Box::new(SummaryReporterBuilder::new(
323+
inner,
324+
workspace_path,
305325
Box::new(std::io::stdout()),
306326
run_command.flags.verbose,
307327
Some(self.make_summary_writer()),
308328
self.program_name.clone(),
309-
);
310-
self.execute_graph(graph, Box::new(builder)).await.map_err(SessionError::EarlyExit)
329+
));
330+
self.execute_graph(graph, builder).await.map_err(SessionError::EarlyExit)
311331
}
312332
}
313333
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
//! Grouped reporter — buffers output per task, prints as a block on completion.
2+
3+
use std::{cell::RefCell, io::Write, process::ExitStatus as StdExitStatus, rc::Rc, sync::Arc};
4+
5+
use owo_colors::Style;
6+
use vite_path::AbsolutePath;
7+
use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind};
8+
9+
use super::{
10+
ColorizeExt, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder,
11+
LeafExecutionReporter, StdioConfig, StdioSuggestion, format_command_with_cache_status,
12+
format_task_label, write_leaf_trailing_output,
13+
};
14+
use crate::session::event::{CacheStatus, CacheUpdateStatus, ExecutionError};
15+
16+
mod writer;
17+
18+
use writer::GroupedWriter;
19+
20+
pub struct GroupedReporterBuilder {
21+
workspace_path: Arc<AbsolutePath>,
22+
writer: Box<dyn Write>,
23+
}
24+
25+
impl GroupedReporterBuilder {
26+
pub fn new(workspace_path: Arc<AbsolutePath>, writer: Box<dyn Write>) -> Self {
27+
Self { workspace_path, writer }
28+
}
29+
}
30+
31+
impl GraphExecutionReporterBuilder for GroupedReporterBuilder {
32+
fn build(self: Box<Self>) -> Box<dyn GraphExecutionReporter> {
33+
Box::new(GroupedGraphReporter {
34+
writer: Rc::new(RefCell::new(self.writer)),
35+
workspace_path: self.workspace_path,
36+
})
37+
}
38+
}
39+
40+
struct GroupedGraphReporter {
41+
writer: Rc<RefCell<Box<dyn Write>>>,
42+
workspace_path: Arc<AbsolutePath>,
43+
}
44+
45+
impl GraphExecutionReporter for GroupedGraphReporter {
46+
fn new_leaf_execution(
47+
&mut self,
48+
display: &ExecutionItemDisplay,
49+
_leaf_kind: &LeafExecutionKind,
50+
) -> Box<dyn LeafExecutionReporter> {
51+
let label = format_task_label(display);
52+
Box::new(GroupedLeafReporter {
53+
writer: Rc::clone(&self.writer),
54+
display: display.clone(),
55+
workspace_path: Arc::clone(&self.workspace_path),
56+
label,
57+
started: false,
58+
grouped_buffer: None,
59+
})
60+
}
61+
62+
fn finish(self: Box<Self>) -> Result<(), ExitStatus> {
63+
let mut writer = self.writer.borrow_mut();
64+
let _ = writer.flush();
65+
Ok(())
66+
}
67+
}
68+
69+
struct GroupedLeafReporter {
70+
writer: Rc<RefCell<Box<dyn Write>>>,
71+
display: ExecutionItemDisplay,
72+
workspace_path: Arc<AbsolutePath>,
73+
label: vite_str::Str,
74+
started: bool,
75+
grouped_buffer: Option<Rc<RefCell<Vec<u8>>>>,
76+
}
77+
78+
impl LeafExecutionReporter for GroupedLeafReporter {
79+
fn start(&mut self, cache_status: CacheStatus) -> StdioConfig {
80+
let line =
81+
format_command_with_cache_status(&self.display, &self.workspace_path, &cache_status);
82+
83+
self.started = true;
84+
85+
// Print labeled command line immediately (before output is buffered).
86+
let labeled_line = vite_str::format!("{} {line}", self.label);
87+
let mut writer = self.writer.borrow_mut();
88+
let _ = writer.write_all(labeled_line.as_bytes());
89+
let _ = writer.flush();
90+
91+
// Create shared buffer for both stdout and stderr.
92+
let buffer = Rc::new(RefCell::new(Vec::new()));
93+
self.grouped_buffer = Some(Rc::clone(&buffer));
94+
95+
StdioConfig {
96+
suggestion: StdioSuggestion::Piped,
97+
stdout_writer: Box::new(GroupedWriter::new(Rc::clone(&buffer))),
98+
stderr_writer: Box::new(GroupedWriter::new(buffer)),
99+
}
100+
}
101+
102+
fn finish(
103+
self: Box<Self>,
104+
_status: Option<StdExitStatus>,
105+
_cache_update_status: CacheUpdateStatus,
106+
error: Option<ExecutionError>,
107+
) {
108+
// Build grouped block: header + buffered output.
109+
let mut extra = Vec::new();
110+
if let Some(ref grouped_buffer) = self.grouped_buffer {
111+
let content = grouped_buffer.borrow();
112+
if !content.is_empty() {
113+
let header = vite_str::format!(
114+
"{} {} {}\n",
115+
"──".style(Style::new().bright_black()),
116+
self.label,
117+
"──".style(Style::new().bright_black())
118+
);
119+
extra.extend_from_slice(header.as_bytes());
120+
extra.extend_from_slice(&content);
121+
}
122+
}
123+
124+
write_leaf_trailing_output(&self.writer, error, self.started, &extra);
125+
}
126+
}
127+
128+
#[cfg(test)]
129+
mod tests {
130+
use vite_task_plan::ExecutionItemKind;
131+
132+
use super::*;
133+
use crate::session::{
134+
event::CacheDisabledReason,
135+
reporter::{
136+
StdioSuggestion,
137+
test_fixtures::{spawn_task, test_path},
138+
},
139+
};
140+
141+
fn leaf_kind(item: &vite_task_plan::ExecutionItem) -> &LeafExecutionKind {
142+
match &item.kind {
143+
ExecutionItemKind::Leaf(kind) => kind,
144+
ExecutionItemKind::Expanded(_) => panic!("test fixture item must be a Leaf"),
145+
}
146+
}
147+
148+
#[test]
149+
fn always_suggests_piped() {
150+
let task = spawn_task("build");
151+
let item = &task.items[0];
152+
153+
let builder = Box::new(GroupedReporterBuilder::new(test_path(), Box::new(std::io::sink())));
154+
let mut reporter = builder.build();
155+
let mut leaf = reporter.new_leaf_execution(&item.execution_item_display, leaf_kind(item));
156+
let stdio_config = leaf.start(CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata));
157+
assert_eq!(stdio_config.suggestion, StdioSuggestion::Piped);
158+
}
159+
}

0 commit comments

Comments
 (0)