diff --git a/Cargo.lock b/Cargo.lock index d1676e23..b29d29fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -403,6 +403,7 @@ dependencies = [ "assert_cmd", "clap", "html5ever", + "libc", "markup5ever_rcdom", "once_cell", "regex", diff --git a/Cargo.toml b/Cargo.toml index 19afabb3..e025ec42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ unicode-width = ">=0.1, <0.2" rstest = "0.18" assert_cmd = "2" tempfile = "3" +libc = ">=0.2, <0.3" [lints.clippy] pedantic = "warn" diff --git a/src/io.rs b/src/io.rs index e4c71c7a..cb30bea4 100644 --- a/src/io.rs +++ b/src/io.rs @@ -4,30 +4,51 @@ use std::{fs, path::Path}; use crate::process::{process_stream, process_stream_no_wrap}; -/// Rewrite a file in place with wrapped tables. +/// Read `path`, process the contents with `f`, and write the result back. +/// +/// This helper encapsulates the common pattern used by [`rewrite`] and +/// [`rewrite_no_wrap`]. /// /// # Errors /// Returns an error if reading or writing the file fails. -pub fn rewrite(path: &Path) -> std::io::Result<()> { +fn rewrite_with(path: &Path, f: F) -> std::io::Result<()> +where + F: Fn(&[String]) -> Vec, +{ let text = fs::read_to_string(path)?; let lines: Vec = text.lines().map(str::to_string).collect(); - let fixed = process_stream(&lines); - fs::write(path, fixed.join("\n") + "\n") + let fixed = f(&lines); + let output = if fixed.is_empty() { + String::new() + } else { + fixed.join("\n") + "\n" + }; + fs::write(path, output) } +/// Rewrite a file in place with wrapped tables. +/// +/// # Errors +/// Returns an error if reading or writing the file fails. +pub fn rewrite(path: &Path) -> std::io::Result<()> { rewrite_with(path, process_stream) } + /// Rewrite a file in place without wrapping text. /// /// # Errors /// Returns an error if reading or writing the file fails. pub fn rewrite_no_wrap(path: &Path) -> std::io::Result<()> { - let text = fs::read_to_string(path)?; - let lines: Vec = text.lines().map(str::to_string).collect(); - let fixed = process_stream_no_wrap(&lines); - fs::write(path, fixed.join("\n") + "\n") + rewrite_with(path, process_stream_no_wrap) } #[cfg(test)] mod tests { + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + use std::{fs::Permissions, path::Path}; + + #[cfg(unix)] + use libc; + use rstest::rstest; use tempfile::tempdir; use super::*; @@ -51,4 +72,58 @@ mod tests { let out = fs::read_to_string(&file).unwrap(); assert_eq!(out, "| A | B |\n| 1 | 2 |\n"); } + + #[cfg(unix)] + fn can_write_as_root() -> bool { + // SAFETY: `geteuid()` has no side effects and is safe to call in tests. + let uid = unsafe { libc::geteuid() }; + uid == 0 + } + + fn assert_permission_error_or_root_success(result: std::io::Result<()>) { + #[cfg(unix)] + if can_write_as_root() { + assert!(result.is_ok()); + } else { + let err = result.expect_err("expected permission denied error"); + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); + } + #[cfg(not(unix))] + { + let err = result.expect_err("expected permission denied error"); + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); + } + } + + #[rstest] + #[case(rewrite)] + #[case(rewrite_no_wrap)] + fn missing_file_error(#[case] rewrite_fn: fn(&Path) -> std::io::Result<()>) { + let dir = tempdir().unwrap(); + let file = dir.path().join("missing.md"); + let err = rewrite_fn(&file).expect_err("expected error for missing file"); + assert_eq!(err.kind(), std::io::ErrorKind::NotFound); + } + + #[rstest] + #[case(rewrite)] + #[case(rewrite_no_wrap)] + fn permission_denied_error(#[case] rewrite_fn: fn(&Path) -> std::io::Result<()>) { + let dir = tempdir().unwrap(); + let file = dir.path().join("deny.md"); + fs::write(&file, "data").unwrap(); + fs::set_permissions(&file, Permissions::from_mode(0o444)).unwrap(); + let result = rewrite_fn(&file); + assert_permission_error_or_root_success(result); + } + + #[test] + fn rewrite_empty_file_no_extra_newline() { + let dir = tempdir().unwrap(); + let file = dir.path().join("empty.md"); + fs::write(&file, "").unwrap(); + rewrite(&file).unwrap(); + let contents = fs::read_to_string(&file).unwrap(); + assert!(contents.is_empty()); + } }