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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ tracing-subscriber = "0.3"
uuid = { version = "1", features = ["v4"] }
rmp-serde = "1"
globset = "0.4"
tempfile = "3"

[dev-dependencies]
proptest = "1"
assert_cmd = "2"
predicates = "3"
tempfile = "3"
pretty_assertions = "1"

[package.metadata.docs.rs]
Expand Down
26 changes: 22 additions & 4 deletions src/output.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::fs;
use std::io::Write;
use std::path::Path;

use crate::error::Error;
Expand Down Expand Up @@ -58,11 +59,28 @@ pub fn templates_to_yaml(templates: &[String]) -> Result<String, Error> {
}

/// Write a YAML string to a file, creating parent directories if needed.
///
/// Uses an atomic write strategy: content is first written to a temporary file
/// in the same directory, then renamed into place. If the write fails (e.g.
/// disk full, permission denied), the original target file is left unchanged.
pub fn write_yaml(content: &str, path: &Path) -> Result<(), Error> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, content)?;
let parent = path.parent().ok_or_else(|| {
Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"output path has no parent directory",
))
})?;
// Handle empty parent (relative path like "spec.yaml" → parent is "")
let parent = if parent.as_os_str().is_empty() {
Path::new(".")
} else {
parent
};
fs::create_dir_all(parent)?;

let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
tmp.write_all(content.as_bytes())?;
tmp.persist(path).map_err(|e| Error::Io(e.error))?;
Ok(())
}

Expand Down
67 changes: 67 additions & 0 deletions tests/output_atomic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
use std::path::Path;

#[test]
fn successful_write_creates_file() {
let dir = tempfile::TempDir::new().unwrap();
let output = dir.path().join("spec.yaml");
mitm2openapi::output::write_yaml("hello: world\n", &output).unwrap();
assert_eq!(std::fs::read_to_string(&output).unwrap(), "hello: world\n");
}

#[test]
fn overwrite_existing_file() {
let dir = tempfile::TempDir::new().unwrap();
let output = dir.path().join("spec.yaml");
mitm2openapi::output::write_yaml("v1: yes\n", &output).unwrap();
mitm2openapi::output::write_yaml("v2: yes\n", &output).unwrap();
assert_eq!(std::fs::read_to_string(&output).unwrap(), "v2: yes\n");
}

#[test]
fn creates_parent_directories() {
let dir = tempfile::TempDir::new().unwrap();
let output = dir.path().join("nested").join("dir").join("spec.yaml");
mitm2openapi::output::write_yaml("nested: yes\n", &output).unwrap();
assert_eq!(std::fs::read_to_string(&output).unwrap(), "nested: yes\n");
}

#[cfg(target_os = "linux")]
#[test]
fn partial_write_preserves_target() {
use std::os::unix::fs::PermissionsExt;

let dir = tempfile::TempDir::new().unwrap();
let output = dir.path().join("spec.yaml");

mitm2openapi::output::write_yaml("original: yes\n", &output).unwrap();
let original = std::fs::read_to_string(&output).unwrap();
assert_eq!(original, "original: yes\n");

let mut perms = std::fs::metadata(dir.path()).unwrap().permissions();
perms.set_mode(0o500);
std::fs::set_permissions(dir.path(), perms).unwrap();

let result = mitm2openapi::output::write_yaml("new: content\n", &output);
assert!(result.is_err(), "write to read-only dir should fail");

let mut perms = std::fs::metadata(dir.path()).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(dir.path(), perms).unwrap();

let after = std::fs::read_to_string(&output).unwrap();
assert_eq!(
after, original,
"original file must remain untouched on write failure"
);
}

#[cfg(unix)]
#[test]
fn write_to_nonexistent_parent_fails_gracefully() {
// /nonexistent at filesystem root requires privilege on Unix; on Windows a
// leading slash without a drive letter is interpreted relative to the CWD,
// which would succeed and invert the assertion. Gate to Unix.
let result =
mitm2openapi::output::write_yaml("test\n", Path::new("/nonexistent/dir/spec.yaml"));
assert!(result.is_err());
}
Loading