Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes

## Unreleased

- `--output-file` on a path that already exists says so and suggests a new path, instead of showing an OS error; any other refusal keeps its cause and gets the same hint. Both say that no change was sent.
- `-o` is short for `--output`, as in `voltage wallets list -o json`.

## 0.2.0 - 2026-10-22
Expand Down
18 changes: 17 additions & 1 deletion src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,22 @@ pub struct Output {
file: Option<File>,
}

/// Reserve `--output-file`. It is reserved before any request, so a refusal sent nothing; the
/// error says why the path was refused and what to pass instead.
fn reserve_output_file(path: &Path) -> Result<File> {
new_private(path).map_err(|error| {
if path.symlink_metadata().is_ok() {
Error::usage(format!(
"{} already exists; --output-file writes only to a new file",
path.display()
))
.with_hint("Pass a path that does not exist yet, or move the existing file. No change was sent.")
} else {
error.and_hint("Pass a path in a directory you can write to. No change was sent.")
}
})
}

impl Output {
/// `output_file` is reserved immediately as a new owner-only file so a secret never
/// waits on a path that another process could create first.
Expand All @@ -196,7 +212,7 @@ impl Output {
show_secrets: bool,
output_file: Option<&Path>,
) -> Result<Self> {
let file = output_file.map(new_private).transpose()?;
let file = output_file.map(reserve_output_file).transpose()?;
Ok(Self {
format,
show_secrets,
Expand Down
49 changes: 49 additions & 0 deletions tests/cli_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2557,6 +2557,55 @@ async fn a_described_change_sends_nothing_records_nothing_and_omits_private_valu
assert!(server.received_requests().await.unwrap().is_empty());
}

#[tokio::test]
async fn output_file_refusals_name_the_path_problem_and_send_nothing() {
let (server, dir) = fixture().await;
let existing = dir.path().join("result.json");
std::fs::write(&existing, "kept").unwrap();
let delete = ["wallets", "delete", WALLET, "--org", ORG, "--execute"];
let refused = cli(dir.path(), &server)
.args(delete)
.arg("--output-file")
.arg(&existing)
.assert()
.code(2);
let error = &json_error(&refused)["error"];
assert_eq!(
error["message"],
format!(
"{} already exists; --output-file writes only to a new file",
existing.display()
)
);
assert_eq!(
error["hint"],
"Pass a path that does not exist yet, or move the existing file. No change was sent."
);
assert_eq!(std::fs::read_to_string(&existing).unwrap(), "kept");

// Any other failure keeps its cause and gets the same next step.
let missing_dir = dir.path().join("missing").join("result.json");
let refused = cli(dir.path(), &server)
.args(delete)
.arg("--output-file")
.arg(&missing_dir)
.assert()
.code(2);
let error = &json_error(&refused)["error"];
assert!(
error["message"].as_str().unwrap().starts_with(&format!(
"Cannot reserve output {}: ",
missing_dir.display()
)),
"{error}"
);
assert_eq!(
error["hint"],
"Pass a path in a directory you can write to. No change was sent."
);
assert!(server.received_requests().await.unwrap().is_empty());
}

#[tokio::test]
async fn changes_are_described_unless_execute_or_voltage_execute_asks_to_send_them() {
let (server, dir) = fixture().await;
Expand Down
Loading