Skip to content

Commit b9a0450

Browse files
committed
feat(cli): 리포트 파일 출력 옵션 추가
--output 경로로 audit/doctor/fix 리포트를 저장하고 기본 stdout 동작은 유지합니다. 비 dry-run fix는 output target을 먼저 검증해 리포트 쓰기 실패 전에 대상 파일을 변경하지 않도록 했습니다. Closes #94
1 parent 2b9a576 commit b9a0450

10 files changed

Lines changed: 357 additions & 104 deletions

File tree

bin/maximus.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ function parseCompatInvocation(args) {
208208
let pathArg;
209209
const unsupportedFlags = [];
210210
const commandNames = new Set(["audit", "doctor", "fix"]);
211-
const valueFlags = new Set(["--only", "--skip", "--fail-on", "--fix-id", "--fix-prefix", "--format"]);
211+
const valueFlags = new Set(["--only", "--skip", "--fail-on", "--fix-id", "--fix-prefix", "--format", "--output"]);
212212
const passthroughFlags = new Set(["--dry-run", "--json"]);
213213

214214
for (let index = 0; index < args.length; index += 1) {
@@ -370,7 +370,7 @@ function formatCompatHelp() {
370370
" maximus fix [path] --dry-run [--json]",
371371
" maximus help",
372372
"",
373-
"Rust is the canonical Maximus runtime. When no Rust runtime is available, the bundled JS compatibility path stays as frozen reference-only fallback for legacy-compatible commands without Maximus config files or Rust-only flags. `--only`, `--skip`, `--fail-on`, `--diff`, `--fix-id`, `--fix-prefix`, and `--format` require the Rust runtime, and `fix` is only available with `--dry-run`.",
373+
"Rust is the canonical Maximus runtime. When no Rust runtime is available, the bundled JS compatibility path stays as frozen reference-only fallback for legacy-compatible commands without Maximus config files or Rust-only flags. `--only`, `--skip`, `--fail-on`, `--diff`, `--fix-id`, `--fix-prefix`, `--format`, and `--output` require the Rust runtime, and `fix` is only available with `--dry-run`.",
374374
].join("\n");
375375
}
376376

crates/maximus-cli/src/args.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub struct Flags {
1111
pub fix_prefixes: Vec<String>,
1212
pub help: bool,
1313
pub output_format: OutputFormat,
14+
pub output_path: Option<OsString>,
1415
pub only_checks: Option<Vec<String>>,
1516
pub skip_checks: Option<Vec<String>>,
1617
}
@@ -108,6 +109,9 @@ where
108109
"--format",
109110
)?;
110111
}
112+
Some("--output") => {
113+
flags.output_path = Some(next_output_value(tokens.next())?);
114+
}
111115
Some("--skip") => {
112116
let value = next_option_value(tokens.next(), "--skip")?;
113117
let values = split_csv_values(&value, "--skip")?;
@@ -153,6 +157,20 @@ fn next_option_value(value: Option<OsString>, flag: &'static str) -> Result<OsSt
153157
Ok(value)
154158
}
155159

160+
fn next_output_value(value: Option<OsString>) -> Result<OsString, ArgsError> {
161+
let Some(value) = value else {
162+
return Err(ArgsError::MissingValue("--output"));
163+
};
164+
165+
match value.to_str() {
166+
Some("") => Err(ArgsError::EmptyValue("--output")),
167+
Some(candidate) if candidate.starts_with('-') && candidate != "-" => {
168+
Err(ArgsError::MissingValue("--output"))
169+
}
170+
_ => Ok(value),
171+
}
172+
}
173+
156174
fn split_csv_values(value: &OsString, flag: &'static str) -> Result<Vec<String>, ArgsError> {
157175
let values = value
158176
.to_string_lossy()
@@ -226,6 +244,7 @@ mod tests {
226244
fix_prefixes: Vec::new(),
227245
help: false,
228246
output_format: OutputFormat::Json,
247+
output_path: None,
229248
only_checks: None,
230249
skip_checks: None,
231250
},
@@ -284,6 +303,30 @@ mod tests {
284303
assert!(parsed.flags.diff);
285304
}
286305

306+
#[test]
307+
fn parse_args_collects_output_path_and_stdout_marker() {
308+
let parsed = parse_args(["audit", "--json", "--output", "reports/audit.json"])
309+
.expect("args should parse");
310+
311+
assert_eq!(
312+
parsed.flags.output_path,
313+
Some(OsString::from("reports/audit.json"))
314+
);
315+
316+
let parsed = parse_args(["audit", "--format", "markdown", "--output", "-"])
317+
.expect("stdout marker should parse");
318+
319+
assert_eq!(parsed.flags.output_path, Some(OsString::from("-")));
320+
}
321+
322+
#[test]
323+
fn parse_args_errors_when_output_path_value_is_missing() {
324+
let error =
325+
parse_args(["audit", "--output", "--json"]).expect_err("output path should fail");
326+
327+
assert_eq!(error, ArgsError::MissingValue("--output"));
328+
}
329+
287330
#[test]
288331
fn parse_args_collects_format_output_values() {
289332
let parsed = parse_args(["audit", "--format", "markdown"]).expect("args should parse");

crates/maximus-cli/src/main.rs

Lines changed: 81 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use std::env;
1111
use std::error::Error;
1212
use std::ffi::OsStr;
1313
use std::fmt::{Display, Formatter};
14-
use std::io;
14+
use std::io::{self, Write};
1515
use std::process;
1616

1717
use maximus_checks::{
@@ -20,9 +20,9 @@ use maximus_checks::{
2020
};
2121
use maximus_core::{
2222
apply_fixes, find_ignore_root, load_ignore_file_pattern_sources, load_maximus_config,
23-
preview_fixes, scope_ignore_patterns, select_fix_plans, select_planned_fixes, AuditResult,
24-
EnvTemplateRenderOptions, FailOnLevel, FixPlan, FixSelector, LoadConfigError, MaximusConfig,
25-
PlannedFix,
23+
prepare_text_write, preview_fixes, scope_ignore_patterns, select_fix_plans,
24+
select_planned_fixes, write_text, AuditResult, EnvTemplateRenderOptions, FailOnLevel, FixPlan,
25+
FixSelector, LoadConfigError, MaximusConfig, PlannedFix,
2626
};
2727

2828
use crate::args::{parse_args, ArgsError, Flags, OutputFormat};
@@ -192,6 +192,9 @@ fn run_fix_command(
192192
}
193193
let selected_fixes = select_fix_plans(&initial.result.fixes, &selector);
194194
let selected_initial = result_with_selected_fixes(&initial.result, selected_fixes.clone());
195+
if !flags.dry_run {
196+
prepare_report_output(flags)?;
197+
}
195198
let previewed = if flags.dry_run && flags.diff {
196199
Some(preview_fixes(&planned)?)
197200
} else {
@@ -232,41 +235,25 @@ fn run_fix_command(
232235
}
233236

234237
fn print_audit_report(flags: &Flags, result: &AuditResult) -> Result<(), CliError> {
235-
match flags.output_format {
236-
crate::args::OutputFormat::Text => {
237-
println!("{}", report_text::format_audit_report(result));
238-
}
239-
crate::args::OutputFormat::Json => {
240-
println!("{}", report_json::render_audit_result(result)?);
241-
}
242-
crate::args::OutputFormat::Markdown => {
243-
println!("{}", report_markdown::format_audit_report(result));
244-
}
245-
crate::args::OutputFormat::Sarif => {
246-
println!("{}", report_sarif::render_audit_result(result)?);
247-
}
248-
}
238+
let report = match flags.output_format {
239+
crate::args::OutputFormat::Text => report_text::format_audit_report(result),
240+
crate::args::OutputFormat::Json => report_json::render_audit_result(result)?,
241+
crate::args::OutputFormat::Markdown => report_markdown::format_audit_report(result),
242+
crate::args::OutputFormat::Sarif => report_sarif::render_audit_result(result)?,
243+
};
249244

250-
Ok(())
245+
write_report_output(flags, &report)
251246
}
252247

253248
fn print_doctor_report(flags: &Flags, result: &AuditResult) -> Result<(), CliError> {
254-
match flags.output_format {
255-
crate::args::OutputFormat::Text => {
256-
println!("{}", report_text::format_doctor_report(result));
257-
}
258-
crate::args::OutputFormat::Json => {
259-
println!("{}", report_json::render_audit_result(result)?);
260-
}
261-
crate::args::OutputFormat::Markdown => {
262-
println!("{}", report_markdown::format_doctor_report(result));
263-
}
264-
crate::args::OutputFormat::Sarif => {
265-
println!("{}", report_sarif::render_doctor_result(result)?);
266-
}
267-
}
249+
let report = match flags.output_format {
250+
crate::args::OutputFormat::Text => report_text::format_doctor_report(result),
251+
crate::args::OutputFormat::Json => report_json::render_audit_result(result)?,
252+
crate::args::OutputFormat::Markdown => report_markdown::format_doctor_report(result),
253+
crate::args::OutputFormat::Sarif => report_sarif::render_doctor_result(result)?,
254+
};
268255

269-
Ok(())
256+
write_report_output(flags, &report)
270257
}
271258

272259
#[allow(clippy::too_many_arguments)]
@@ -280,66 +267,73 @@ fn print_fix_report(
280267
preview_report: Option<&str>,
281268
previews: Option<&[maximus_core::PreviewedFix]>,
282269
) -> Result<(), CliError> {
283-
match flags.output_format {
284-
crate::args::OutputFormat::Text => {
285-
println!(
286-
"{}",
287-
report_text::format_fix_result(
288-
flags.dry_run,
289-
target_dir,
290-
initial,
291-
applied,
292-
final_result,
293-
selected_fixes,
294-
preview_report,
295-
)
296-
);
297-
}
298-
crate::args::OutputFormat::Json => {
299-
println!(
300-
"{}",
301-
report_json::render_fix_result(
302-
flags.dry_run,
303-
target_dir,
304-
initial,
305-
applied,
306-
final_result,
307-
previews,
308-
)?
309-
);
310-
}
311-
crate::args::OutputFormat::Markdown => {
312-
println!(
313-
"{}",
314-
report_markdown::format_fix_result(
315-
flags.dry_run,
316-
target_dir,
317-
initial,
318-
applied,
319-
final_result,
320-
selected_fixes,
321-
preview_report,
322-
)
323-
);
270+
let report = match flags.output_format {
271+
crate::args::OutputFormat::Text => report_text::format_fix_result(
272+
flags.dry_run,
273+
target_dir,
274+
initial,
275+
applied,
276+
final_result,
277+
selected_fixes,
278+
preview_report,
279+
),
280+
crate::args::OutputFormat::Json => report_json::render_fix_result(
281+
flags.dry_run,
282+
target_dir,
283+
initial,
284+
applied,
285+
final_result,
286+
previews,
287+
)?,
288+
crate::args::OutputFormat::Markdown => report_markdown::format_fix_result(
289+
flags.dry_run,
290+
target_dir,
291+
initial,
292+
applied,
293+
final_result,
294+
selected_fixes,
295+
preview_report,
296+
),
297+
crate::args::OutputFormat::Sarif => report_sarif::render_fix_result(
298+
flags.dry_run,
299+
target_dir,
300+
initial,
301+
applied,
302+
final_result,
303+
previews,
304+
)?,
305+
};
306+
307+
write_report_output(flags, &report)
308+
}
309+
310+
fn write_report_output(flags: &Flags, report: &str) -> Result<(), CliError> {
311+
let content = format!("{report}\n");
312+
313+
match flags.output_path.as_deref() {
314+
Some(output_path) if output_path != OsStr::new("-") => {
315+
write_text(std::path::Path::new(output_path), &content)?;
324316
}
325-
crate::args::OutputFormat::Sarif => {
326-
println!(
327-
"{}",
328-
report_sarif::render_fix_result(
329-
flags.dry_run,
330-
target_dir,
331-
initial,
332-
applied,
333-
final_result,
334-
previews,
335-
)?
336-
);
317+
_ => {
318+
io::stdout().lock().write_all(content.as_bytes())?;
337319
}
338320
}
339321

340322
Ok(())
341323
}
342324

325+
fn prepare_report_output(flags: &Flags) -> Result<(), CliError> {
326+
if let Some(output_path) = flags
327+
.output_path
328+
.as_deref()
329+
.filter(|output_path| *output_path != OsStr::new("-"))
330+
{
331+
prepare_text_write(std::path::Path::new(output_path))?;
332+
}
333+
334+
Ok(())
335+
}
336+
343337
fn resolve_effective_config(
344338
target_dir: &std::path::Path,
345339
flags: &Flags,

crates/maximus-cli/src/report_text.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ pub fn format_help() -> String {
1111
"Bring order to chaotic configs.",
1212
"",
1313
"Usage",
14-
" maximus audit [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--format <format>] [--json]",
15-
" maximus doctor [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--format <format>] [--json]",
16-
" maximus fix [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--dry-run] [--diff] [--env-source-comments] [--fix-id <id>] [--fix-prefix <prefix>] [--format <format>] [--json]",
14+
" maximus audit [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--format <format>] [--json] [--output <path>]",
15+
" maximus doctor [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--format <format>] [--json] [--output <path>]",
16+
" maximus fix [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--dry-run] [--diff] [--env-source-comments] [--fix-id <id>] [--fix-prefix <prefix>] [--format <format>] [--json] [--output <path>]",
1717
" maximus help",
1818
]
1919
.join("\n")
@@ -379,9 +379,9 @@ mod tests {
379379
"Bring order to chaotic configs.",
380380
"",
381381
"Usage",
382-
" maximus audit [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--format <format>] [--json]",
383-
" maximus doctor [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--format <format>] [--json]",
384-
" maximus fix [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--dry-run] [--diff] [--env-source-comments] [--fix-id <id>] [--fix-prefix <prefix>] [--format <format>] [--json]",
382+
" maximus audit [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--format <format>] [--json] [--output <path>]",
383+
" maximus doctor [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--format <format>] [--json] [--output <path>]",
384+
" maximus fix [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--dry-run] [--diff] [--env-source-comments] [--fix-id <id>] [--fix-prefix <prefix>] [--format <format>] [--json] [--output <path>]",
385385
" maximus help",
386386
]
387387
.join("\n")

crates/maximus-cli/tests/cli_help.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ fn no_args_prints_help() {
2222
"Bring order to chaotic configs.",
2323
"",
2424
"Usage",
25-
" maximus audit [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--format <format>] [--json]",
26-
" maximus doctor [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--format <format>] [--json]",
27-
" maximus fix [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--dry-run] [--diff] [--env-source-comments] [--fix-id <id>] [--fix-prefix <prefix>] [--format <format>] [--json]",
25+
" maximus audit [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--format <format>] [--json] [--output <path>]",
26+
" maximus doctor [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--format <format>] [--json] [--output <path>]",
27+
" maximus fix [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--dry-run] [--diff] [--env-source-comments] [--fix-id <id>] [--fix-prefix <prefix>] [--format <format>] [--json] [--output <path>]",
2828
" maximus help",
2929
"",
3030
]
@@ -43,7 +43,7 @@ fn help_subcommand_prints_usage() {
4343
assert!(String::from_utf8(output.stdout)
4444
.expect("stdout should be utf8")
4545
.contains(
46-
"maximus audit [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--format <format>] [--json]"
46+
"maximus audit [path] [--only <checks>] [--skip <checks>] [--fail-on <level>] [--format <format>] [--json] [--output <path>]"
4747
));
4848
}
4949

0 commit comments

Comments
 (0)