use std::time::Duration;
use ntfs_mac_core::{
Config, Error, ExitCode, RunOptions, config, device, error, runner, validate_device_id,
validate_label, validate_mount_point,
};
#[test]
fn runner_returns_zero_for_echo() {
let out = runner::run("echo", &["hello"], &RunOptions::default()).unwrap();
assert!(out.success());
assert_eq!(out.stdout.trim(), "hello");
}
#[test]
fn runner_returns_nonzero_for_false() {
let out = runner::run("false", &[], &RunOptions::default()).unwrap();
assert!(!out.success());
assert!(out.status > 0);
}
#[test]
fn runner_captures_stderr() {
let out = runner::run(
"sh",
&["-c", "echo err 1>&2; echo ok"],
&RunOptions::default(),
)
.unwrap();
assert!(out.success());
assert!(out.stderr.contains("err"));
}
#[test]
fn runner_timeout_kills_long_process() {
let out = runner::run(
"sleep",
&["60"],
&RunOptions {
timeout: Some(Duration::from_millis(500)),
..Default::default()
},
)
.unwrap();
assert_eq!(out.status, runner::TIMEOUT_STATUS);
assert!(
out.timed_out,
"a killed child must flag itself as timed out"
);
}
#[test]
fn runner_timeout_flags_are_cleared_on_success() {
let out = runner::run("echo", &["hello"], &RunOptions::default()).unwrap();
assert!(out.success());
assert!(!out.timed_out);
}
#[test]
fn runner_timeout_sends_sigterm_before_sigkill() {
let tmp = tempfile::tempdir().unwrap();
let marker = tmp.path().join("got-term");
let script = format!(
"trap 'touch {}' TERM; while :; do :; done",
marker.display()
);
let out = runner::run(
"sh",
&["-c", &script],
&RunOptions {
timeout: Some(Duration::from_millis(300)),
..Default::default()
},
)
.unwrap();
assert!(out.timed_out);
assert_eq!(out.status, runner::TIMEOUT_STATUS);
assert!(
marker.exists(),
"the child never received SIGTERM, so its trap could not have run"
);
}
#[test]
fn runner_timeout_survives_orphaned_grandchildren() {
let started = std::time::Instant::now();
let out = runner::run(
"sh",
&["-c", "sleep 30 & wait"],
&RunOptions {
timeout: Some(Duration::from_millis(200)),
..Default::default()
},
)
.unwrap();
assert!(out.timed_out);
assert_eq!(out.status, runner::TIMEOUT_STATUS);
let total = started.elapsed();
assert!(
total < Duration::from_secs(10),
"run() was held hostage by the orphan: {total:?}"
);
}
#[test]
fn run_expect_success_maps_timeout_to_command_timed_out() {
let limit = Duration::from_millis(300);
let err = runner::run_expect_success(
"sleep",
&["60"],
&RunOptions {
timeout: Some(limit),
..Default::default()
},
)
.unwrap_err();
match err {
Error::CommandTimedOut { cmd, timeout } => {
assert_eq!(cmd, "sleep");
assert_eq!(timeout, limit);
}
other => panic!("expected CommandTimedOut, got {other:?}"),
}
}
#[test]
fn timed_out_error_does_not_leak_the_internal_sentinel() {
let err = Error::CommandTimedOut {
cmd: "fsck_ntfs".into(),
timeout: Duration::from_secs(30),
};
let msg = err.to_string();
assert!(msg.contains("fsck_ntfs"), "{msg}");
assert!(msg.contains("timed out"), "{msg}");
assert!(
!msg.contains("150"),
"the internal status sentinel must not reach the user: {msg}"
);
}
#[test]
fn runner_missing_binary_returns_command_failed() {
let err = runner::run(
"definitely-not-a-real-binary-xyz",
&[],
&RunOptions::default(),
)
.unwrap_err();
match err {
Error::CommandFailed { status, .. } => {
assert!(status < 0); }
_ => panic!("expected CommandFailed"),
}
}
#[test]
fn which_returns_path_for_echo() {
let p = runner::which("echo").unwrap();
assert!(p.is_absolute());
assert!(p.exists());
}
#[test]
fn which_missing_binary_errors() {
let err = runner::which("definitely-not-a-real-binary-xyz").unwrap_err();
match err {
Error::MissingDependency { binary, .. } => {
assert!(binary.contains("definitely-not-a-real-binary"));
}
_ => panic!("expected MissingDependency"),
}
}
#[test]
fn config_round_trip() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.toml");
let cfg = Config {
mount_base: "/Volumes/Backup".into(),
require_confirmation: false,
mount_options: vec!["noowners".into(), "uid=501".into()],
..Default::default()
};
config::save_config(&cfg, &path).unwrap();
let loaded = config::load_config(&path).unwrap();
assert_eq!(loaded.mount_base, "/Volumes/Backup");
assert!(!loaded.require_confirmation);
assert_eq!(loaded.mount_options.len(), 2);
}
#[test]
fn config_missing_file_returns_default() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("does-not-exist.toml");
let cfg = config::load_config(&path).unwrap();
assert_eq!(cfg.mount_base, Config::default().mount_base);
}
#[test]
fn config_parse_error_on_garbage() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("garbage.toml");
std::fs::write(&path, "this is not [valid toml at all").unwrap();
let err = config::load_config(&path).unwrap_err();
match err {
Error::Serde(msg) => {
assert!(
msg.contains("toml") || msg.contains("expected"),
"unexpected: {msg}"
);
}
other => panic!("expected Serde error, got {other:?}"),
}
}
#[test]
fn device_parse_empty_plist() {
let err = device::parse_diskutil_plist("[]").unwrap_err();
let _ = err; }
#[test]
fn validate_device_id_full_matrix() {
for id in ["disk0", "disk2s2", "disk10s3", "disk100s200"] {
assert!(validate_device_id(id).is_ok(), "{id} should be valid");
}
for id in [
"", "disk", "disk0s", "disk0sabc", "notadisk", "disk 0", "/dev/disk0", "disk0/2", ] {
assert!(validate_device_id(id).is_err(), "{id} should be invalid");
}
}
#[test]
fn validate_mount_point_full_matrix() {
for mp in ["/Volumes/MyDisk", "/tmp/mount", "/"] {
assert!(validate_mount_point(mp).is_ok(), "{mp} should be valid");
}
for mp in ["", "relative", "relative/path", "/path/\n/newline"] {
assert!(
validate_mount_point(mp).is_err(),
"{mp:?} should be invalid"
);
}
}
#[test]
fn validate_label_full_matrix() {
for label in ["A", "MyData"] {
assert!(validate_label(label).is_ok(), "{label} should be valid");
}
let long_label = "a".repeat(32);
assert!(
validate_label(&long_label).is_ok(),
"32-char label should be valid"
);
assert!(validate_label("").is_err());
assert!(validate_label("a/b").is_err());
assert!(validate_label("a\\b").is_err());
let too_long = "a".repeat(33);
assert!(validate_label(&too_long).is_err());
}
#[test]
fn exit_code_conversions() {
assert_eq!(u8::from(ExitCode::Ok), 0);
assert_eq!(u8::from(ExitCode::Failure), 1);
assert_eq!(u8::from(ExitCode::MissingDependency), 2);
assert_eq!(u8::from(ExitCode::UserCancelled), 3);
assert_eq!(u8::from(ExitCode::ConfirmationMismatch), 4);
assert_eq!(u8::from(ExitCode::InvalidArgument), 5);
}
#[test]
fn error_display_contains_context() {
let err = Error::InvalidArgument("bad device".into());
assert!(err.to_string().contains("bad device"));
let err = Error::Cancelled;
assert!(err.to_string().contains("cancel"));
let err = Error::NoMatch {
pattern: "disk9999".into(),
};
assert!(err.to_string().contains("disk9999"));
}
#[test]
fn error_io_conversion() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
let err: Error = io_err.into();
assert!(matches!(err, Error::Io(_)));
}
#[test]
fn result_alias_is_stdout() {
fn foo() -> error::Result<i32> {
Ok(42)
}
assert_eq!(foo().unwrap(), 42);
}