Skip to content

Commit e8fb15f

Browse files
committed
feat(cli): add minimal agent output mode
1 parent 024c390 commit e8fb15f

12 files changed

Lines changed: 378 additions & 41 deletions

apps/oxfmt/src/cli/command.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ pub struct FormatCommand {
2323
#[bpaf(external(mode))]
2424
pub mode: Mode,
2525
#[bpaf(external)]
26+
pub output_options: OutputOptions,
27+
#[bpaf(external)]
2628
pub config_options: ConfigOptions,
2729
#[bpaf(external)]
2830
pub ignore_options: IgnoreOptions,
@@ -128,6 +130,14 @@ fn output_mode() -> impl bpaf::Parser<OutputMode> {
128130
bpaf::construct!([write, check, list_different]).group_help("Output Options:")
129131
}
130132

133+
/// Output options
134+
#[derive(Debug, Clone, Bpaf)]
135+
pub struct OutputOptions {
136+
/// Reduce CLI output for agents: print only changed paths and one-line diagnostics
137+
#[bpaf(long("minimal"), switch, hide)]
138+
pub minimal: bool,
139+
}
140+
131141
/// Migration Source
132142
#[cfg(feature = "napi")]
133143
#[derive(Debug, Clone)]
@@ -173,3 +183,16 @@ pub struct RuntimeOptions {
173183
#[bpaf(argument("INT"), hide_usage)]
174184
pub threads: Option<usize>,
175185
}
186+
187+
#[cfg(test)]
188+
mod tests {
189+
use super::{Mode, OutputMode, format_command};
190+
191+
#[test]
192+
fn minimal_output_option() {
193+
let command = format_command().run_inner(&["--minimal", "--check", "."]).unwrap();
194+
195+
assert!(command.output_options.minimal);
196+
assert!(matches!(command.mode, Mode::Cli(OutputMode::Check)));
197+
}
198+
}

apps/oxfmt/src/cli/reporter.rs

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use oxc_diagnostics::{
2-
Error, GraphicalReportHandler,
3-
reporter::{DiagnosticReporter, DiagnosticResult},
2+
Error, GraphicalReportHandler, Severity,
3+
reporter::{DiagnosticReporter, DiagnosticResult, Info},
44
};
55

66
// This reporter is used with stderr and displays diagnostics only in a graphical way.
@@ -36,3 +36,85 @@ impl DiagnosticReporter for DefaultReporter {
3636
Some(output)
3737
}
3838
}
39+
40+
#[derive(Debug, Default)]
41+
pub struct MinimalReporter;
42+
43+
impl DiagnosticReporter for MinimalReporter {
44+
fn finish(&mut self, _result: &DiagnosticResult) -> Option<String> {
45+
None
46+
}
47+
48+
fn supports_minified_file_fallback(&self) -> bool {
49+
false
50+
}
51+
52+
fn render_error(&mut self, error: Error) -> Option<String> {
53+
Some(format_minimal(&error))
54+
}
55+
}
56+
57+
fn format_minimal(diagnostic: &Error) -> String {
58+
let Info { start, filename, .. } = Info::new(diagnostic);
59+
let filename = if filename.is_empty() {
60+
diagnostic
61+
.source_code()
62+
.and_then(miette::SourceCode::name)
63+
.map_or_else(|| "<unknown>".to_string(), ToString::to_string)
64+
} else {
65+
filename
66+
};
67+
let severity = match diagnostic.severity() {
68+
Some(Severity::Warning) => "warning",
69+
Some(Severity::Advice) => "advice",
70+
_ => "error",
71+
};
72+
let message = compact_message(&diagnostic.to_string());
73+
74+
if start.line == 0 {
75+
format!("{filename}: {severity}: {message}\n")
76+
} else {
77+
format!("{filename}:{}:{}: {severity}: {message}\n", start.line, start.column)
78+
}
79+
}
80+
81+
fn compact_message(message: &str) -> String {
82+
let mut compact = String::new();
83+
for word in message.split_whitespace() {
84+
if !compact.is_empty() {
85+
compact.push(' ');
86+
}
87+
compact.push_str(word);
88+
}
89+
compact
90+
}
91+
92+
#[cfg(test)]
93+
mod tests {
94+
use oxc_diagnostics::{NamedSource, OxcDiagnostic, reporter::DiagnosticReporter};
95+
use oxc_span::Span;
96+
97+
use super::MinimalReporter;
98+
99+
#[test]
100+
fn minimal_reporter_with_label() {
101+
let mut reporter = MinimalReporter;
102+
let error = OxcDiagnostic::error("Unexpected token")
103+
.with_label(Span::new(0, 1))
104+
.with_source_code(NamedSource::new("file.js", "!"));
105+
106+
assert_eq!(reporter.render_error(error).unwrap(), "file.js:1:1: error: Unexpected token\n");
107+
}
108+
109+
#[test]
110+
fn minimal_reporter_without_label() {
111+
let mut reporter = MinimalReporter;
112+
let error = OxcDiagnostic::error("Failed to save file\npermission denied")
113+
.with_source_code(NamedSource::new("file.js", ""));
114+
115+
assert_eq!(
116+
reporter.render_error(error).unwrap(),
117+
"file.js: error: Failed to save file permission denied\n"
118+
);
119+
}
120+
}

apps/oxfmt/src/cli/service.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,20 @@ pub struct FormatService {
2222
cwd: Box<Path>,
2323
format_mode: OutputMode,
2424
formatter: SourceFormatter,
25+
minimal_output: bool,
2526
}
2627

2728
impl FormatService {
28-
pub fn new<T>(cwd: T, format_mode: OutputMode, formatter: SourceFormatter) -> Self
29+
pub fn new<T>(
30+
cwd: T,
31+
format_mode: OutputMode,
32+
formatter: SourceFormatter,
33+
minimal_output: bool,
34+
) -> Self
2935
where
3036
T: Into<Box<Path>>,
3137
{
32-
Self { cwd: cwd.into(), format_mode, formatter }
38+
Self { cwd: cwd.into(), format_mode, formatter, minimal_output }
3339
}
3440

3541
/// Process entries as they are received from the channel
@@ -40,7 +46,9 @@ impl FormatService {
4046
tx_success: &mpsc::Sender<SuccessResult>,
4147
) {
4248
rx_entry.into_iter().par_bridge().for_each(|strategy| {
43-
let start_time = matches!(self.format_mode, OutputMode::Check).then(Instant::now);
49+
let start_time = (matches!(self.format_mode, OutputMode::Check)
50+
&& !self.minimal_output)
51+
.then(Instant::now);
4452

4553
let path: Arc<Path> = Arc::clone(strategy.path());
4654
let Ok(source_text) = utils::read_to_string(&path) else {
@@ -108,7 +116,7 @@ impl FormatService {
108116
.cow_replace('\\', "/")
109117
.to_string();
110118

111-
if matches!(self.format_mode, OutputMode::Check) {
119+
if matches!(self.format_mode, OutputMode::Check) && !self.minimal_output {
112120
let elapsed = start_time.unwrap().elapsed().as_millis();
113121
SuccessResult::Changed(format!("{display_path} ({elapsed}ms)"))
114122
} else {

apps/oxfmt/src/cli/walk_runner.rs

Lines changed: 61 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
use std::{env, io::BufWriter, path::PathBuf, sync::mpsc, time::Instant};
22

3-
use oxc_diagnostics::DiagnosticService;
3+
use oxc_diagnostics::{DiagnosticService, reporter::DiagnosticReporter};
44

55
use super::{
66
command::{FormatCommand, Mode, OutputMode},
7-
reporter::DefaultReporter,
7+
reporter::{DefaultReporter, MinimalReporter},
88
resolve::resolve_ignore_paths,
99
result::CliRunResult,
1010
service::{FormatService, SuccessResult},
@@ -69,13 +69,20 @@ impl WalkRunner {
6969
let start_time = Instant::now();
7070

7171
let cwd = self.cwd;
72-
let FormatCommand { paths, mode, config_options, ignore_options, runtime_options } =
73-
self.options;
72+
let FormatCommand {
73+
paths,
74+
mode,
75+
output_options,
76+
config_options,
77+
ignore_options,
78+
runtime_options,
79+
} = self.options;
7480
// If `napi` feature is disabled, there is no other mode.
7581
#[cfg_attr(not(feature = "napi"), expect(irrefutable_let_patterns))]
7682
let Mode::Cli(format_mode) = mode else {
7783
unreachable!("`WalkRunner` should only be called with Mode::Cli");
7884
};
85+
let minimal_output = output_options.minimal;
7986
let num_of_threads = rayon::current_num_threads();
8087

8188
// Find and load root config file
@@ -140,10 +147,14 @@ impl WalkRunner {
140147
// Collect format results (changed paths or unchanged count)
141148
let (tx_success, rx_success) = mpsc::channel();
142149
// Diagnostic from formatting service
143-
let (mut diagnostic_service, tx_error) =
144-
DiagnosticService::new(Box::new(DefaultReporter::default()));
150+
let reporter: Box<dyn DiagnosticReporter> = if minimal_output {
151+
Box::new(MinimalReporter)
152+
} else {
153+
Box::new(DefaultReporter::default())
154+
};
155+
let (mut diagnostic_service, tx_error) = DiagnosticService::new(reporter);
145156

146-
if matches!(format_mode, OutputMode::Check) {
157+
if matches!(format_mode, OutputMode::Check) && !minimal_output {
147158
utils::print_and_flush(stdout, "Checking formatting...\n");
148159
utils::print_and_flush(stdout, "\n");
149160
}
@@ -161,7 +172,8 @@ impl WalkRunner {
161172
// Spawn formatting service on a dedicated thread so it doesn't occupy the rayon pool.
162173
// It just blocks on `rx_entry` waiting for entries; `par_bridge()` inside still uses rayon.
163174
std::thread::spawn(move || {
164-
let format_service = FormatService::new(cwd, format_mode, source_formatter);
175+
let format_service =
176+
FormatService::new(cwd, format_mode, source_formatter, minimal_output);
165177
format_service.run_streaming(rx_entry, &tx_error_for_format, &tx_success);
166178
});
167179

@@ -206,7 +218,11 @@ impl WalkRunner {
206218
// Print sorted changed file paths to stdout
207219
if !changed_paths.is_empty() {
208220
changed_paths.sort_unstable();
209-
utils::print_and_flush(stdout, &changed_paths.join("\n"));
221+
if minimal_output {
222+
utils::print_and_flush(stdout, &format!("{}\n", changed_paths.join("\n")));
223+
} else {
224+
utils::print_and_flush(stdout, &changed_paths.join("\n"));
225+
}
210226
}
211227

212228
// Then, output diagnostics errors to stderr
@@ -218,6 +234,10 @@ impl WalkRunner {
218234
// Count the processed files
219235
let total_target_files_count = changed_paths.len() + unchanged_count + error_count;
220236
let print_stats = |stdout, stderr| {
237+
if minimal_output {
238+
return;
239+
}
240+
221241
utils::print_and_flush(
222242
stdout,
223243
&format!(
@@ -239,24 +259,32 @@ impl WalkRunner {
239259
// Check if no files were found
240260
if total_target_files_count == 0 {
241261
if runtime_options.no_error_on_unmatched_pattern {
242-
utils::print_and_flush(stderr, "No files found matching the given patterns.\n");
243-
print_stats(stdout, stderr);
262+
if !minimal_output {
263+
utils::print_and_flush(stderr, "No files found matching the given patterns.\n");
264+
print_stats(stdout, stderr);
265+
}
244266
return CliRunResult::None;
245267
}
246268

247-
utils::print_and_flush(
248-
stderr,
249-
"Expected at least one target file. All matched files may have been excluded by ignore rules.\n",
250-
);
269+
if minimal_output {
270+
utils::print_and_flush(stderr, "No files found.\n");
271+
} else {
272+
utils::print_and_flush(
273+
stderr,
274+
"Expected at least one target file. All matched files may have been excluded by ignore rules.\n",
275+
);
276+
}
251277
return CliRunResult::NoFilesFound;
252278
}
253279

254280
if 0 < error_count {
255281
// Each error is already printed in reporter
256-
utils::print_and_flush(
257-
stderr,
258-
"Error occurred when checking code style in the above files.\n",
259-
);
282+
if !minimal_output {
283+
utils::print_and_flush(
284+
stderr,
285+
"Error occurred when checking code style in the above files.\n",
286+
);
287+
}
260288
return CliRunResult::FormatFailed;
261289
}
262290

@@ -266,19 +294,23 @@ impl WalkRunner {
266294
(OutputMode::ListDifferent, _) => CliRunResult::FormatMismatch,
267295
// `--check` outputs friendly summary
268296
(OutputMode::Check, 0) => {
269-
utils::print_and_flush(stdout, "All matched files use the correct format.\n");
270-
print_stats(stdout, stderr);
297+
if !minimal_output {
298+
utils::print_and_flush(stdout, "All matched files use the correct format.\n");
299+
print_stats(stdout, stderr);
300+
}
271301
CliRunResult::FormatSucceeded
272302
}
273303
(OutputMode::Check, changed_count) => {
274-
utils::print_and_flush(stdout, "\n\n");
275-
utils::print_and_flush(
276-
stdout,
277-
&format!(
278-
"Format issues found in above {changed_count} files. Run without `--check` to fix.\n",
279-
),
280-
);
281-
print_stats(stdout, stderr);
304+
if !minimal_output {
305+
utils::print_and_flush(stdout, "\n\n");
306+
utils::print_and_flush(
307+
stdout,
308+
&format!(
309+
"Format issues found in above {changed_count} files. Run without `--check` to fix.\n",
310+
),
311+
);
312+
print_stats(stdout, stderr);
313+
}
282314
CliRunResult::FormatMismatch
283315
}
284316
// Default (write) outputs only stats

apps/oxlint/src/command/lint.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,9 +253,19 @@ pub struct WarningOptions {
253253
#[derive(Debug, Clone, Bpaf)]
254254
pub struct OutputOptions {
255255
/// Use a specific output format. Possible values:
256-
/// `checkstyle`, `default`, `github`, `gitlab`, `json`, `junit`, `stylish`, `unix`
256+
/// `checkstyle`, `default`, `github`, `gitlab`, `json`, `junit`, `minimal`, `stylish`, `unix`
257257
#[bpaf(long, short, fallback_with(default_output_format), hide_usage)]
258258
pub format: OutputFormat,
259+
260+
/// Reduce CLI output for agents. Alias for `--format minimal`.
261+
#[bpaf(long("minimal"), switch, hide)]
262+
pub minimal: bool,
263+
}
264+
265+
impl OutputOptions {
266+
pub fn effective_format(&self) -> OutputFormat {
267+
if self.minimal { OutputFormat::Minimal } else { self.format }
268+
}
259269
}
260270

261271
#[expect(clippy::unnecessary_wraps)]
@@ -544,6 +554,7 @@ mod lint_options {
544554
assert!(!options.fix_options.fix);
545555
assert!(!options.list_rules);
546556
assert_eq!(options.output_options.format, OutputFormat::Default);
557+
assert!(!options.output_options.minimal);
547558
}
548559

549560
#[test]
@@ -604,7 +615,20 @@ mod lint_options {
604615
fn format() {
605616
let options = get_lint_options("-f json");
606617
assert_eq!(options.output_options.format, OutputFormat::Json);
618+
assert!(!options.output_options.minimal);
607619
assert!(options.paths.is_empty());
620+
621+
let options = get_lint_options("-f minimal");
622+
assert_eq!(options.output_options.format, OutputFormat::Minimal);
623+
assert_eq!(options.output_options.effective_format(), OutputFormat::Minimal);
624+
}
625+
626+
#[test]
627+
fn minimal() {
628+
let options = get_lint_options("--minimal");
629+
assert_eq!(options.output_options.format, OutputFormat::Default);
630+
assert!(options.output_options.minimal);
631+
assert_eq!(options.output_options.effective_format(), OutputFormat::Minimal);
608632
}
609633

610634
#[test]

0 commit comments

Comments
 (0)