use std::{io, path::Path, sync::OnceLock, time::Instant};
use camino::{Utf8Path, Utf8PathBuf};
use cap_std::{ambient_authority, fs_utf8::Dir};
use metrics::{Unit, counter, describe_counter, describe_histogram, histogram};
use tracing::{debug, trace};
use super::{
document::SourceDocument,
line_endings::LineEndingCounts,
swap::{create_temporary_file, remove_temporary_file, write_and_swap},
};
use crate::process::{process_stream, process_stream_no_wrap};
pub(super) fn rewrite_with<F>(path: &Path, operation: &str, f: F) -> std::io::Result<()>
where
F: Fn(&[String]) -> Vec<String>,
{
let (directory, name) = open_parent(path)?;
let text = directory.read_to_string(&name)?;
let document = SourceDocument::parse(&text);
let path_text = path.to_string_lossy();
report_line_endings(document.counts(), operation, path_text.as_ref());
let lines: Vec<String> = document.body().lines().map(str::to_string).collect();
let fixed = f(&lines);
if !replace_file_if_unchanged(&directory, &name, &text, &document.render(&fixed))? {
return Err(io::Error::other(format!(
"{path_text} changed while it was being rewritten; leaving the changed file alone"
)));
}
Ok(())
}
fn report_line_endings(counts: LineEndingCounts, operation: &str, path: &str) {
debug!(
operation,
path = %path,
crlf_count = counts.crlf_count,
lone_lf_count = counts.lone_lf_count,
selected_ending = counts.ending.as_str(),
"selected the majority line ending"
);
}
fn open_parent(path: &Path) -> io::Result<(Dir, Utf8PathBuf)> {
let path = Utf8Path::from_path(path).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("path is not valid UTF-8: {}", path.display()),
)
})?;
let parent = path
.parent()
.filter(|parent| !parent.as_str().is_empty())
.unwrap_or(Utf8Path::new("."));
let name = path.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("no file name in {path}"),
)
})?;
let directory = Dir::open_ambient_dir(parent, ambient_authority())?;
Ok((directory, Utf8PathBuf::from(name)))
}
fn describe_metrics() {
static DESCRIPTION: OnceLock<()> = OnceLock::new();
DESCRIPTION.get_or_init(register_metrics);
}
pub(super) fn register_metrics() {
describe_counter!(
"mdtablefix_io_replace_total",
"Atomic file replacements attempted, by outcome"
);
describe_histogram!(
"mdtablefix_io_replace_duration_seconds",
Unit::Seconds,
"Duration of atomic file replacements, by outcome"
);
describe_counter!(
"mdtablefix_io_temporary_name_collisions_total",
"Temporary names rejected because they were already taken"
);
describe_counter!(
"mdtablefix_io_temporary_name_exhausted_total",
"Replacements abandoned because every candidate temporary name was taken"
);
describe_counter!(
"mdtablefix_io_temporary_cleanup_failures_total",
"Temporary files a failed replacement could not remove"
);
describe_counter!(
"mdtablefix_io_symlink_declined_total",
"Symbolic-link targets declined rather than replaced"
);
}
#[tracing::instrument(level = "debug", skip(directory, contents), fields(path = %path))]
pub fn replace_file(directory: &Dir, path: &Utf8Path, contents: &str) -> io::Result<()> {
replace(directory, path, None, contents).map(|_| ())
}
#[tracing::instrument(level = "debug", skip(directory, expected, contents), fields(path = %path))]
pub fn replace_file_if_unchanged(
directory: &Dir,
path: &Utf8Path,
expected: &str,
contents: &str,
) -> io::Result<bool> {
replace(directory, path, Some(expected), contents)
}
fn replace(
directory: &Dir,
path: &Utf8Path,
expected: Option<&str>,
contents: &str,
) -> io::Result<bool> {
describe_metrics();
let started = Instant::now();
let outcome = replace_inner(directory, path, expected, contents);
let result = match &outcome {
Ok(true) => "success",
Ok(false) => "unchanged",
Err(_) => "failure",
};
counter!("mdtablefix_io_replace_total", "outcome" => result).increment(1);
histogram!(
"mdtablefix_io_replace_duration_seconds",
"outcome" => result
)
.record(started.elapsed().as_secs_f64());
outcome
}
fn replace_inner(
directory: &Dir,
path: &Utf8Path,
expected: Option<&str>,
contents: &str,
) -> io::Result<bool> {
let metadata = directory.symlink_metadata(path).inspect_err(|error| {
debug!(error_category = ?error.kind(), "replacement failed");
})?;
if metadata.file_type().is_symlink() {
counter!("mdtablefix_io_symlink_declined_total").increment(1);
debug!(error_category = "symlink_target", "rewrite declined");
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("refusing to replace the symlink {path}; rewrite its target instead"),
));
}
trace!("target metadata read");
let permissions = metadata.permissions();
let (temp_path, file) = create_temporary_file(directory, path).inspect_err(|error| {
debug!(error_category = ?error.kind(), "replacement failed");
})?;
let outcome = write_and_swap(
directory,
&temp_path,
path,
expected,
contents,
&permissions,
file,
);
match outcome {
Ok(true) => Ok(true),
Ok(false) => {
debug!("replacement declined: the target changed since it was read");
remove_temporary_file(directory, &temp_path)?;
Ok(false)
}
Err(error) => {
debug!(error_category = ?error.kind(), "replacement failed");
remove_failed_temporary_file(directory, &temp_path);
Err(error)
}
}
}
pub(super) fn remove_failed_temporary_file(directory: &Dir, temp_path: &Utf8Path) {
match remove_temporary_file(directory, temp_path) {
Ok(()) => trace!("temporary file removed after failure"),
Err(cleanup_error) => {
counter!("mdtablefix_io_temporary_cleanup_failures_total").increment(1);
debug!(
error_category = ?cleanup_error.kind(),
"temporary file cleanup failed"
);
}
}
}
pub fn rewrite(path: &Path) -> std::io::Result<()> { rewrite_with(path, "rewrite", process_stream) }
pub fn rewrite_no_wrap(path: &Path) -> std::io::Result<()> {
rewrite_with(path, "rewrite_no_wrap", process_stream_no_wrap)
}