feat(daemon): make the download folder a live setting in both modes
The full-surface audit found the settings story half-true: `config set download_dir` and the TUI's settings form edited a file a running daemon never rereads, while the attached TUI displayed the laptop's path and discarded the edit entirely. - `download.set_dir`: a session-wide move, published in the contract; the per-transfer destination stays refused. `daemon.status` reports it and the next transfer lands there. - `config set download_dir` and `config set shared_dirs` push to a live daemon the way `shares add` does, through one shared helper; the socket probe is paid only by the keys a daemon owns. - The attached TUI seeds its settings form from the daemon's status, pushes both folders over the socket as typed, reports a partial refusal as exactly that, and creates no client-side directories. It reads the daemon token from the resolved config — with the token only in config.toml it could not attach over TCP — and settings persistence honours --config instead of hard-coding the default path. - `search.wishlist` records a start time, so an attached window renders it as running rather than instantly finished. The rest is the coverage the audit demanded: CLI-level token auth (wrong, missing, and valid over TCP), `daemon status|stop` with nothing running, --sort, --limit, --pick first, --password-cmd, --log-file, --upload-slots, the config-named download folder in both modes, and headless settings-form tests attached and local. Every guard was checked by disabling what it protects. Both e2e modes run green: 94 CLI tests each way, 16 protocol, 285 unit, 34 lib.
This commit is contained in:
@@ -126,6 +126,12 @@ fn records(output: &Output) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The first record of a `--json` run, parsed.
|
||||
fn json_record(output: &Output) -> serde_json::Value {
|
||||
serde_json::from_str(records(output).first().expect("one record"))
|
||||
.expect("valid JSON")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// No server required
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3462,3 +3468,335 @@ fn adding_a_share_takes_effect_on_a_running_daemon() {
|
||||
"and should have indexed what is in it: {after}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Setting the download folder reaches a daemon that is already running.
|
||||
///
|
||||
/// Locally `config set download_dir` means "from the next run on". A daemon
|
||||
/// *is* that next run and it is already going, so without pushing the change
|
||||
/// every transfer would keep landing in the old folder while the config file
|
||||
/// claims otherwise — the trap `shares add` already closes.
|
||||
#[test]
|
||||
fn setting_the_download_folder_takes_effect_on_a_running_daemon() {
|
||||
if mode() != Mode::Daemon {
|
||||
println!(
|
||||
"skipped: there is no running session to update in local mode"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let server = server_or_skip!();
|
||||
let user = "cli_e2e_live_dl_dir";
|
||||
let socket = server.daemon_state(user).join("daemon.sock");
|
||||
let at = socket.display().to_string();
|
||||
let config = Scratch::new("live-dl-config");
|
||||
let config_file = config.path().join("config.toml");
|
||||
let moved = Scratch::new("live-dl-target");
|
||||
|
||||
let set = run(&[
|
||||
"--config",
|
||||
config_file.to_str().expect("utf-8 path"),
|
||||
"--daemon",
|
||||
&at,
|
||||
"config",
|
||||
"set",
|
||||
"download_dir",
|
||||
&moved.display(),
|
||||
]);
|
||||
assert_eq!(code(&set), EXIT_OK, "stderr: {}", stderr(&set));
|
||||
|
||||
// The daemon reports the new folder as its own...
|
||||
let status =
|
||||
run(&["--no-config", "--json", "--daemon", &at, "daemon", "status"]);
|
||||
assert_eq!(code(&status), EXIT_OK, "stderr: {}", stderr(&status));
|
||||
assert_eq!(
|
||||
json_record(&status)["download_dir"],
|
||||
moved.display(),
|
||||
"status must name the folder transfers will now land in"
|
||||
);
|
||||
|
||||
// `config set shared_dirs` reaches the same running daemon: the folder
|
||||
// is indexed now, not at the next restart.
|
||||
let offered = Scratch::new("live-dl-offered");
|
||||
std::fs::write(offered.path().join("cli_probe_offered.bin"), probe_bytes())
|
||||
.expect("share file");
|
||||
let set_shares = run(&[
|
||||
"--config",
|
||||
config_file.to_str().expect("utf-8 path"),
|
||||
"--daemon",
|
||||
&at,
|
||||
"config",
|
||||
"set",
|
||||
"shared_dirs",
|
||||
&offered.display(),
|
||||
]);
|
||||
assert_eq!(
|
||||
code(&set_shares),
|
||||
EXIT_OK,
|
||||
"stderr: {}",
|
||||
stderr(&set_shares)
|
||||
);
|
||||
let listing =
|
||||
run(&["--no-config", "--json", "--daemon", &at, "shares", "status"]);
|
||||
assert_eq!(code(&listing), EXIT_OK, "stderr: {}", stderr(&listing));
|
||||
let indexed = json_record(&listing);
|
||||
assert!(
|
||||
indexed["directories"]
|
||||
.as_array()
|
||||
.expect("directories is an array")
|
||||
.iter()
|
||||
.any(|d| d == &offered.display()),
|
||||
"the daemon should serve the folder the config now names: {indexed}"
|
||||
);
|
||||
|
||||
// ...and the next transfer actually lands there.
|
||||
let share = Scratch::new("live-dl-share");
|
||||
std::fs::write(share.path().join("cli_probe_moved.bin"), probe_bytes())
|
||||
.expect("share file");
|
||||
let _sharer = server.client("cli_e2e_sharer_moved", vec![share.display()]);
|
||||
settle();
|
||||
|
||||
let fetched = run(&[
|
||||
"--no-config",
|
||||
"--quiet",
|
||||
"--daemon",
|
||||
&at,
|
||||
"get",
|
||||
"cli_probe_moved",
|
||||
"--timeout",
|
||||
"40",
|
||||
]);
|
||||
assert_eq!(code(&fetched), EXIT_OK, "stderr: {}", stderr(&fetched));
|
||||
let reported = records(&fetched);
|
||||
assert_eq!(reported.len(), 1, "one file, one record");
|
||||
assert!(
|
||||
reported[0].trim().starts_with(&moved.display()),
|
||||
"the bytes must land in the folder that was just set, got {}",
|
||||
reported[0]
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(reported[0].trim()).expect("downloaded file"),
|
||||
probe_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
/// The configured `download_dir` is where a download lands when no flag names
|
||||
/// one — the local half of the promise the daemon test above makes.
|
||||
#[test]
|
||||
fn a_download_lands_in_the_folder_the_config_names() {
|
||||
if mode() != Mode::Local {
|
||||
println!("skipped: the daemon's folder wins in daemon mode");
|
||||
return;
|
||||
}
|
||||
let server = server_or_skip!();
|
||||
let share = Scratch::new("share");
|
||||
let target = Scratch::new("configured-downloads");
|
||||
std::fs::write(share.path().join("cli_probe_cfgdir.bin"), probe_bytes())
|
||||
.expect("share file");
|
||||
let _sharer = server.client("cli_e2e_sharer_cfg", vec![share.display()]);
|
||||
settle();
|
||||
|
||||
let config = Scratch::new("dl-dir-config");
|
||||
let config_file = config.path().join("config.toml");
|
||||
let set = run(&[
|
||||
"--config",
|
||||
config_file.to_str().expect("utf-8 path"),
|
||||
"config",
|
||||
"set",
|
||||
"download_dir",
|
||||
&target.display(),
|
||||
]);
|
||||
assert_eq!(code(&set), EXIT_OK, "stderr: {}", stderr(&set));
|
||||
|
||||
let fetched = cli_with_config(
|
||||
&server,
|
||||
"cli_e2e_cfg_fetcher",
|
||||
&config_file,
|
||||
&["get", "cli_probe_cfgdir", "--timeout", "40"],
|
||||
);
|
||||
assert_eq!(code(&fetched), EXIT_OK, "stderr: {}", stderr(&fetched));
|
||||
let reported = records(&fetched);
|
||||
assert_eq!(reported.len(), 1, "one file, one record");
|
||||
assert!(
|
||||
reported[0].trim().starts_with(&target.display()),
|
||||
"the config file names where downloads go, got {}",
|
||||
reported[0]
|
||||
);
|
||||
}
|
||||
|
||||
/// With nothing running, the daemon control commands say so and exit with the
|
||||
/// connection code — a script must be able to branch on "is it up?".
|
||||
#[test]
|
||||
fn daemon_status_and_stop_without_a_daemon_are_connection_errors() {
|
||||
for command in ["status", "stop"] {
|
||||
let output = run(&["--no-config", "daemon", command]);
|
||||
assert_eq!(
|
||||
code(&output),
|
||||
EXIT_CONNECTION,
|
||||
"daemon {command}: {}",
|
||||
stderr(&output)
|
||||
);
|
||||
assert!(
|
||||
stderr(&output).contains("no daemon is running"),
|
||||
"daemon {command} should say what is wrong, got: {}",
|
||||
stderr(&output)
|
||||
);
|
||||
assert!(stdout(&output).is_empty(), "stdout must stay data-only");
|
||||
}
|
||||
}
|
||||
|
||||
/// `--sort size` leads with the largest file, which is what a script that
|
||||
/// wants "the full album rip, not the sample" branches on.
|
||||
#[test]
|
||||
fn search_sorted_by_size_leads_with_the_largest_file() {
|
||||
let server = server_or_skip!();
|
||||
let share = Scratch::new("share");
|
||||
std::fs::write(share.path().join("cli_probe_sort_small.bin"), [7u8; 64])
|
||||
.expect("share file");
|
||||
std::fs::write(
|
||||
share.path().join("cli_probe_sort_large.bin"),
|
||||
vec![7u8; 4096],
|
||||
)
|
||||
.expect("share file");
|
||||
let _sharer = server.client("cli_e2e_sharer_sort", vec![share.display()]);
|
||||
settle();
|
||||
|
||||
let output = cli(
|
||||
&server,
|
||||
"cli_e2e_sorter",
|
||||
&[
|
||||
"--search-timeout",
|
||||
"5",
|
||||
"search",
|
||||
"cli_probe_sort",
|
||||
"--sort",
|
||||
"size",
|
||||
],
|
||||
);
|
||||
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
|
||||
let reported = records(&output);
|
||||
assert!(reported.len() >= 2, "both files answer, got {reported:?}");
|
||||
assert!(
|
||||
reported[0].contains("cli_probe_sort_large"),
|
||||
"largest first under --sort size, got {reported:?}"
|
||||
);
|
||||
|
||||
// `--limit 1` keeps only the leader.
|
||||
let capped = cli(
|
||||
&server,
|
||||
"cli_e2e_sorter",
|
||||
&[
|
||||
"--search-timeout",
|
||||
"5",
|
||||
"search",
|
||||
"cli_probe_sort",
|
||||
"--sort",
|
||||
"size",
|
||||
"--limit",
|
||||
"1",
|
||||
],
|
||||
);
|
||||
assert_eq!(code(&capped), EXIT_OK, "stderr: {}", stderr(&capped));
|
||||
let capped = records(&capped);
|
||||
assert_eq!(capped.len(), 1, "--limit 1 keeps one record");
|
||||
assert!(
|
||||
capped[0].contains("cli_probe_sort_large"),
|
||||
"and it is the ranked leader, got {capped:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `--pick first` takes the first arrival without waiting out the window.
|
||||
#[test]
|
||||
fn get_pick_first_downloads_the_first_arrival() {
|
||||
let server = server_or_skip!();
|
||||
let share = Scratch::new("share");
|
||||
let target = Scratch::new("downloads");
|
||||
std::fs::write(share.path().join("cli_probe_first.bin"), probe_bytes())
|
||||
.expect("share file");
|
||||
let _sharer = server.client("cli_e2e_sharer_first", vec![share.display()]);
|
||||
settle();
|
||||
|
||||
let output = cli(
|
||||
&server,
|
||||
"cli_e2e_first_picker",
|
||||
&[
|
||||
"--download-dir",
|
||||
&target.display(),
|
||||
"get",
|
||||
"cli_probe_first",
|
||||
"--pick",
|
||||
"first",
|
||||
"--timeout",
|
||||
"40",
|
||||
],
|
||||
);
|
||||
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
|
||||
let reported = records(&output);
|
||||
assert_eq!(reported.len(), 1, "--pick first downloads one file");
|
||||
assert_eq!(
|
||||
std::fs::read(reported[0].trim()).expect("downloaded file"),
|
||||
probe_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
/// The password can come from a command, `pass`-manager style: nothing
|
||||
/// sensitive in argv, nothing in the environment.
|
||||
#[test]
|
||||
fn the_password_can_come_from_a_command() {
|
||||
let server = server_or_skip!();
|
||||
let _owner = server.client("cli_e2e_cmd_pw", Vec::new());
|
||||
|
||||
let output = run(&[
|
||||
"--no-config",
|
||||
"--quiet",
|
||||
"--server",
|
||||
&server.address(),
|
||||
"--username",
|
||||
"cli_e2e_cmd_pw",
|
||||
"--password-cmd",
|
||||
"echo pw",
|
||||
"--no-listener",
|
||||
"whoami",
|
||||
]);
|
||||
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
|
||||
assert!(
|
||||
records(&output)[0].starts_with("cli_e2e_cmd_pw\t"),
|
||||
"got {:?}",
|
||||
records(&output)
|
||||
);
|
||||
}
|
||||
|
||||
/// `--log-file` keeps diagnostics out of stderr and in a file a bug report
|
||||
/// can attach.
|
||||
#[test]
|
||||
fn a_log_file_captures_what_verbose_would_print() {
|
||||
let server = server_or_skip!();
|
||||
let _owner = server.client("cli_e2e_logged", Vec::new());
|
||||
let logs = Scratch::new("logs");
|
||||
let log_file = logs.path().join("run.log");
|
||||
let output = run(&[
|
||||
"--no-config",
|
||||
"--quiet",
|
||||
"-vvv",
|
||||
"--log-file",
|
||||
log_file.to_str().expect("utf-8 path"),
|
||||
"--username",
|
||||
"cli_e2e_logged",
|
||||
"--password",
|
||||
"pw",
|
||||
"--server",
|
||||
&server.address(),
|
||||
"--no-listener",
|
||||
"whoami",
|
||||
]);
|
||||
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
|
||||
let written =
|
||||
std::fs::read_to_string(&log_file).expect("the log file is created");
|
||||
assert!(
|
||||
!written.trim().is_empty(),
|
||||
"-vvv must leave diagnostics in the file"
|
||||
);
|
||||
assert!(
|
||||
!stderr(&output).contains("DEBUG"),
|
||||
"with a log file, diagnostics stay out of stderr: {}",
|
||||
stderr(&output)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,18 +48,25 @@ struct Daemon {
|
||||
_server: Soulfind,
|
||||
}
|
||||
|
||||
/// The binary with a scrubbed environment: no test may see the developer's
|
||||
/// real daemon, and `state` decides which one it does see.
|
||||
fn cli_command(state: &Path) -> Command {
|
||||
let mut command = Command::new(BIN);
|
||||
for name in CLI_ENV_VARS {
|
||||
command.env_remove(name);
|
||||
}
|
||||
command.env("SOULSEEK_STATE_DIR", state);
|
||||
command.current_dir(std::env::temp_dir());
|
||||
command
|
||||
}
|
||||
|
||||
impl Daemon {
|
||||
fn start() -> Option<Self> {
|
||||
let server = Soulfind::start()?;
|
||||
let state = scratch("daemon-proto");
|
||||
let port = free_port()?;
|
||||
|
||||
let mut command = Command::new(BIN);
|
||||
for name in CLI_ENV_VARS {
|
||||
command.env_remove(name);
|
||||
}
|
||||
command.env("SOULSEEK_STATE_DIR", &state);
|
||||
command.current_dir(std::env::temp_dir());
|
||||
let mut command = cli_command(&state);
|
||||
command.args([
|
||||
"--no-config",
|
||||
"--server",
|
||||
@@ -75,6 +82,10 @@ impl Daemon {
|
||||
"daemon",
|
||||
"--bind",
|
||||
&format!("127.0.0.1:{port}"),
|
||||
// Exercised on every boot: the limit must parse and apply
|
||||
// without disturbing anything else the suite observes.
|
||||
"--upload-slots",
|
||||
"8",
|
||||
]);
|
||||
command.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
let child = command.spawn().ok()?;
|
||||
@@ -521,13 +532,8 @@ fn a_config_driven_remote_client_reports_the_daemons_world() {
|
||||
)
|
||||
.expect("config file");
|
||||
|
||||
let mut command = Command::new(BIN);
|
||||
for name in CLI_ENV_VARS {
|
||||
command.env_remove(name);
|
||||
}
|
||||
let mut command = cli_command(&state);
|
||||
command.env("SOULSEEK_CONFIG_DIR", &config);
|
||||
command.env("SOULSEEK_STATE_DIR", &state);
|
||||
command.current_dir(std::env::temp_dir());
|
||||
command.args(["--json", "whoami"]);
|
||||
let output = command.output().expect("the binary should run");
|
||||
|
||||
@@ -793,3 +799,157 @@ fn a_client_that_attaches_later_sees_what_is_already_there() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The download folder is a session setting, settable over the wire.
|
||||
///
|
||||
/// `download.start` deliberately refuses a per-transfer destination, so
|
||||
/// `download.set_dir` is the only way a remote owner can move where bytes
|
||||
/// land — and `daemon.status` has to report the move, because that status is
|
||||
/// what every attached client trusts for "where did my file go".
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn the_download_folder_moves_when_asked_and_status_says_so() {
|
||||
let daemon = daemon_or_skip!();
|
||||
let mut connection = daemon.connect_socket().expect("socket");
|
||||
connection.authenticate(None).expect("an answer");
|
||||
|
||||
let moved = scratch("moved-downloads").join("deeper");
|
||||
let reply = connection
|
||||
.call(&format!(
|
||||
r#"{{"jsonrpc":"2.0","id":2,"method":"download.set_dir","params":{{"directory":{}}}}}"#,
|
||||
serde_json::json!(moved.to_str().expect("utf-8 path"))
|
||||
))
|
||||
.expect("an answer");
|
||||
assert!(reply.contains(r#""ok":true"#), "got {reply}");
|
||||
assert!(
|
||||
moved.is_dir(),
|
||||
"the daemon creates the folder it will download into"
|
||||
);
|
||||
|
||||
let status = connection
|
||||
.call(r#"{"jsonrpc":"2.0","id":3,"method":"daemon.status"}"#)
|
||||
.expect("an answer");
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&status).expect("valid JSON");
|
||||
assert_eq!(
|
||||
parsed["result"]["download_dir"],
|
||||
moved.to_str().expect("utf-8 path"),
|
||||
"status must name the folder transfers now land in: {status}"
|
||||
);
|
||||
|
||||
// An empty path is refused as bad parameters, not applied as "here".
|
||||
let refused = connection
|
||||
.call(
|
||||
r#"{"jsonrpc":"2.0","id":4,"method":"download.set_dir","params":{"directory":" "}}"#,
|
||||
)
|
||||
.expect("an answer");
|
||||
assert!(refused.contains("-32602"), "got {refused}");
|
||||
let _ = std::fs::remove_dir_all(moved.parent().expect("parent"));
|
||||
}
|
||||
|
||||
/// A wishlist search must read as *running* to the other windows.
|
||||
///
|
||||
/// The daemon can only tell a running search from a finished one by the start
|
||||
/// time it recorded; a wishlist search that never records one renders as
|
||||
/// completed everywhere the moment it goes out.
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn a_wishlist_search_is_reported_as_just_started() {
|
||||
let daemon = daemon_or_skip!();
|
||||
let mut connection = daemon.connect_socket().expect("socket");
|
||||
connection.authenticate(None).expect("an answer");
|
||||
|
||||
let sent = connection
|
||||
.call(
|
||||
r#"{"jsonrpc":"2.0","id":2,"method":"search.wishlist","params":{"query":"wishlist_probe"}}"#,
|
||||
)
|
||||
.expect("an answer");
|
||||
assert!(sent.contains(r#""ok":true"#), "got {sent}");
|
||||
|
||||
let listed = connection
|
||||
.call(r#"{"jsonrpc":"2.0","id":3,"method":"search.list"}"#)
|
||||
.expect("an answer");
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&listed).expect("valid JSON");
|
||||
let age = parsed["result"]["searches"]
|
||||
.as_array()
|
||||
.expect("searches is an array")
|
||||
.iter()
|
||||
.find(|search| search["query"] == "wishlist_probe")
|
||||
.and_then(|search| search["started_secs_ago"].as_u64())
|
||||
.expect("the wishlist search is listed with an age");
|
||||
assert!(
|
||||
age < 60,
|
||||
"a search sent moments ago must read as just started, got {age}s"
|
||||
);
|
||||
}
|
||||
|
||||
/// The upload-slot limit is retunable on a live session.
|
||||
#[test]
|
||||
fn the_upload_slot_limit_can_be_retuned_live() {
|
||||
let daemon = daemon_or_skip!();
|
||||
let mut connection = daemon.connect_tcp().expect("tcp");
|
||||
connection
|
||||
.authenticate(Some(&daemon.token()))
|
||||
.expect("an answer");
|
||||
let reply = connection
|
||||
.call(
|
||||
r#"{"jsonrpc":"2.0","id":2,"method":"upload.slots","params":{"slots":3}}"#,
|
||||
)
|
||||
.expect("an answer");
|
||||
assert!(reply.contains(r#""ok":true"#), "got {reply}");
|
||||
}
|
||||
|
||||
/// A wrong or missing token is the CLI's problem to explain, not just the
|
||||
/// wire's: the user typed `--daemon-token`, so the failure has to come back
|
||||
/// as a connection error rather than a hang or a fallback to local defaults.
|
||||
#[test]
|
||||
fn the_cli_reports_a_bad_token_as_a_connection_error() {
|
||||
let daemon = daemon_or_skip!();
|
||||
let state = scratch("cli-bad-token");
|
||||
let run = |token: Option<&str>| {
|
||||
let mut command = cli_command(&state);
|
||||
command.args([
|
||||
"--no-config",
|
||||
"--daemon",
|
||||
&format!("127.0.0.1:{}", daemon.port),
|
||||
]);
|
||||
if let Some(token) = token {
|
||||
command.args(["--daemon-token", token]);
|
||||
}
|
||||
command.arg("whoami");
|
||||
command.output().expect("the binary should run")
|
||||
};
|
||||
|
||||
let wrong = run(Some(&"0".repeat(64)));
|
||||
assert_eq!(
|
||||
wrong.status.code(),
|
||||
Some(3),
|
||||
"a rejected token is a connection error: {}",
|
||||
String::from_utf8_lossy(&wrong.stderr)
|
||||
);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&wrong.stdout).trim().is_empty(),
|
||||
"a failing command must write nothing to stdout"
|
||||
);
|
||||
|
||||
// No token at all: this state dir never ran a daemon, so there is no
|
||||
// cookie file to fall back on, and TCP without a token is a hard no.
|
||||
let missing = run(None);
|
||||
assert_eq!(
|
||||
missing.status.code(),
|
||||
Some(3),
|
||||
"TCP with no token to offer is a connection error: {}",
|
||||
String::from_utf8_lossy(&missing.stderr)
|
||||
);
|
||||
|
||||
// The right token still works from the same machine state.
|
||||
let right = run(Some(&daemon.token()));
|
||||
assert_eq!(
|
||||
right.status.code(),
|
||||
Some(0),
|
||||
"the real token must keep working: {}",
|
||||
String::from_utf8_lossy(&right.stderr)
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&state);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user