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