From acdb5fc9e6b3c94fe666aaccfbf2343af0b80899 Mon Sep 17 00:00:00 2001 From: Louis Deconinck Date: Sun, 13 Sep 2026 13:21:20 +0200 Subject: [PATCH] feat(cli): add --output to write the JSON report to a file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #166. Large scans already emit a full JSON report, but the only destination was stdout — fine for a pipe into jq, awkward when the report is megabytes and the goal is to keep it. `diskern scan --json --output ` writes the same pretty document to the file instead of printing it; stdout stays empty and a confirmation goes to stderr, so the command composes in scripts either way. The flag requires --json (clap rejects it otherwise, exit 2) because it has no meaning in the human-readable path, and a failed write surfaces the path plus the OS error rather than a bare io failure. End-to-end tests pin the file carrying exactly what --json would print, the rejection without --json, and the exit-1 error on an unwritable path. --- changelog.d/166-json-output-file.added.md | 2 + crates/diskern-cli/README.md | 4 + crates/diskern-cli/src/main.rs | 22 ++++- crates/diskern-cli/tests/end_to_end.rs | 115 ++++++++++++++++++++++ 4 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 changelog.d/166-json-output-file.added.md diff --git a/changelog.d/166-json-output-file.added.md b/changelog.d/166-json-output-file.added.md new file mode 100644 index 0000000..742d101 --- /dev/null +++ b/changelog.d/166-json-output-file.added.md @@ -0,0 +1,2 @@ +- Add `diskern scan --json --output ` to write the JSON scan + report to a file instead of printing it to stdout. diff --git a/crates/diskern-cli/README.md b/crates/diskern-cli/README.md index 7a51bad..9020ebc 100644 --- a/crates/diskern-cli/README.md +++ b/crates/diskern-cli/README.md @@ -20,6 +20,9 @@ diskern scan ~ --exclude ~/Videos --exclude ~/VMs # Full JSON report (for scripting / piping into jq) diskern scan ~/Downloads --json +# Write the JSON report to a file instead of stdout +diskern scan ~/Downloads --json --output report.json + # Add deterministic narration over the finished report diskern scan ~/Downloads --explain ``` @@ -61,6 +64,7 @@ nothing will offer to move it. | `--verdict` | all | `safe`, `review`, `risky` or `protected`. Duplicate sets have no verdict, so they are omitted when this is set. | | `--explain` | off | Print deterministic narration over the finished report. | | `--json` | off | Full report as JSON; the flags above don't apply. | +| `--output ` | stdout | Write the JSON report to a file instead of printing it; requires `--json`. | | `--rules ` | embedded | Load and validate an external rules database; embedded protected rules remain authoritative. | Scanning is always read-only — the CLI never modifies, moves, or deletes diff --git a/crates/diskern-cli/src/main.rs b/crates/diskern-cli/src/main.rs index e80c8c9..1a872ea 100644 --- a/crates/diskern-cli/src/main.rs +++ b/crates/diskern-cli/src/main.rs @@ -32,6 +32,9 @@ enum Command { /// Emit full JSON report instead of a summary #[arg(long)] json: bool, + /// Write the JSON report to a file instead of stdout; requires --json + #[arg(long, value_name = "FILE", requires = "json")] + output: Option, /// Print a deterministic plain-language explanation of the report #[arg(long)] explain: bool, @@ -251,6 +254,7 @@ fn main() -> Result<()> { roots, exclude, json, + output, explain, top, verdict, @@ -267,7 +271,23 @@ fn main() -> Result<()> { if let Some(path) = external_rules { eprintln!("Using external rules database: {}", path.display()); } - println!("{}", serde_json::to_string_pretty(&report)?); + let rendered = serde_json::to_string_pretty(&report)?; + match output { + // Issue #166. A file beats a stdout dump for large + // scans: nothing to page through, and the report + // survives the terminal. stdout stays clean so the + // command composes in scripts either way. + Some(path) => { + std::fs::write(&path, format!("{rendered}\n")).with_context(|| { + format!( + "could not write JSON report to '{}'; check that the directory exists and is writable", + path.display() + ) + })?; + eprintln!("Wrote JSON report to {}", path.display()); + } + None => println!("{rendered}"), + } } else { println!( "Scanned {} file{}.", diff --git a/crates/diskern-cli/tests/end_to_end.rs b/crates/diskern-cli/tests/end_to_end.rs index ae55786..e40385a 100644 --- a/crates/diskern-cli/tests/end_to_end.rs +++ b/crates/diskern-cli/tests/end_to_end.rs @@ -328,6 +328,121 @@ fn scan_json_emits_a_parseable_report_with_the_promised_fields() { ); } +/// Issue #166. `--output` redirects the JSON report to a file instead of +/// flooding stdout — the file must carry exactly the document `--json` +/// alone would print, byte for byte once parsed. +#[test] +fn scan_json_output_writes_the_stdout_report_to_a_file() { + let root = tempdir().unwrap(); + write_verdict_fixture(root.path()); + let out_dir = tempdir().unwrap(); + let report_path = out_dir.path().join("report.json"); + + // A plain --json run over the same fixture is the reference document. + let stdout_run = scan(root.path(), &["--json"]); + assert_eq!( + stdout_run.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&stdout_run.stderr) + ); + + let output = scan( + root.path(), + &["--json", "--output", report_path.to_str().unwrap()], + ); + assert_eq!( + output.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // The point of the flag is avoiding the huge dump: stdout stays + // empty, and the confirmation goes to stderr where it cannot + // corrupt a pipe. + assert!( + output.stdout.is_empty(), + "--output should keep the report off stdout: {}", + String::from_utf8_lossy(&output.stdout) + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains("report.json"), + "no confirmation on stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let written = fs::read(&report_path).expect("report file should exist"); + let from_file: serde_json::Value = + serde_json::from_slice(&written).expect("report file should be a single JSON document"); + let from_stdout: serde_json::Value = + serde_json::from_slice(&stdout_run.stdout).expect("stdout report should parse"); + assert_eq!( + from_file, from_stdout, + "file report differs from what --json prints to stdout" + ); + assert_eq!(from_file["files_scanned"].as_u64(), Some(6)); +} + +#[test] +fn scan_output_without_json_is_rejected() { + // The flag only makes sense in the JSON path, so clap turns the + // invocation down before a scan ever runs: exit 2, no report file, + // nothing on stdout. + let root = tempdir().unwrap(); + let report_path = root.path().join("report.json"); + + let output = scan(root.path(), &["--output", report_path.to_str().unwrap()]); + assert_eq!( + output.status.code(), + Some(2), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + output.stdout.is_empty(), + "rejected invocation wrote to stdout: {}", + String::from_utf8_lossy(&output.stdout) + ); + assert!( + !report_path.exists(), + "rejected invocation still wrote a report file" + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains("--json"), + "error does not name the missing --json: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn scan_output_to_an_unwritable_path_exits_1_with_a_friendly_error() { + // A path inside a directory that does not exist fails portably — + // permission-based fixtures do not, running as root or on Windows. + let root = tempdir().unwrap(); + let report_path = root.path().join("missing/report.json"); + + let output = scan( + root.path(), + &["--json", "--output", report_path.to_str().unwrap()], + ); + assert_eq!( + output.status.code(), + Some(1), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("could not write JSON report") && stderr.contains("missing"), + "error should name the path and the failure:\n{stderr}" + ); + assert!( + !report_path.exists(), + "a partial report file should not be left behind" + ); +} + #[test] fn argument_errors_exit_2_and_write_no_report() { // clap owns these failures: the run never reaches the engine, so the