diffs-rs 0.1.1

Fast terminal diff viewer and embeddable Rust diff engine
Documentation
use std::{
    fs,
    io::{BufWriter, Write},
    os::unix::fs::PermissionsExt,
    path::PathBuf,
    sync::Arc,
};

use anyhow::{Context, Result, ensure};
use diffs_rs::{engine::ReviewId, reading::SourceRead};
use tempfile::{TempDir, TempPath};

pub struct Prepared {
    source: Arc<SourceRead>,
    destination: PathBuf,
    path: TempPath,
    directory: TempDir,
    bytes: u64,
}

pub struct Published {
    pub destination: PathBuf,
    pub bytes: u64,
    pub cleanup: Result<()>,
}

#[derive(Debug)]
pub struct CleanupError(anyhow::Error);

impl std::fmt::Display for CleanupError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "Save staging cleanup failed: {:#}", self.0)
    }
}

impl std::error::Error for CleanupError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        Some(self.0.as_ref())
    }
}

fn cleanup(directory: TempDir) -> Result<()> {
    let path = directory.path().to_owned();
    directory
        .close()
        .with_context(|| format!("Cannot remove private save directory {}", path.display()))
        .map_err(|error| CleanupError(error).into())
}

fn failed(error: anyhow::Error, cleaned: Result<()>) -> anyhow::Error {
    match cleaned {
        Ok(()) => error,
        Err(cleanup) => cleanup.context(format!("Save also failed: {error:#}")),
    }
}

impl Prepared {
    pub fn prepare(
        source: Arc<SourceRead>,
        destination: PathBuf,
        cancel: &impl Fn() -> Result<()>,
    ) -> Result<Self> {
        cancel()?;
        source.check()?;
        let destination = std::path::absolute(destination).context("Cannot resolve save path")?;
        ensure!(
            destination.file_name().is_some(),
            "Choose a destination file"
        );
        match fs::symlink_metadata(&destination) {
            Ok(_) => anyhow::bail!("Destination already exists; choose a new file"),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
            Err(error) => return Err(error).context("Cannot inspect save destination"),
        }
        let parent = destination
            .parent()
            .context("Save destination has no parent")?;
        let directory = tempfile::Builder::new()
            .prefix(".diffs-save-")
            .permissions(fs::Permissions::from_mode(0o700))
            .tempdir_in(parent)
            .context("Cannot create private staging in the destination directory")?;
        let prepared = (|| -> Result<_> {
            let mut file = tempfile::NamedTempFile::new_in(directory.path())
                .context("Cannot create private save file")?;
            let bytes = {
                let mut output = BufWriter::with_capacity(64 * 1024, &mut file);
                let bytes = source.write_to(&mut output, cancel)?;
                output.flush().context("Cannot flush prepared source")?;
                bytes
            };
            file.as_file()
                .sync_all()
                .context("Cannot sync prepared source")?;
            cancel()?;
            source.check()?;
            Ok((file.into_temp_path(), bytes))
        })();
        match prepared {
            Ok((path, bytes)) => Ok(Self {
                source,
                destination,
                path,
                directory,
                bytes,
            }),
            Err(error) => Err(failed(error, cleanup(directory))),
        }
    }

    pub fn discard(self) -> Result<()> {
        let Self {
            path, directory, ..
        } = self;
        drop(path);
        cleanup(directory)
    }

    pub fn publish(self, review: ReviewId) -> Result<Published> {
        let checked = (|| {
            ensure!(
                self.source.range().review == review,
                "Save belongs to another review"
            );
            self.source.check()
        })();
        if let Err(error) = checked {
            return Err(failed(error, self.discard()));
        }
        let Self {
            destination,
            path,
            directory,
            bytes,
            ..
        } = self;
        match path.persist_noclobber(&destination) {
            Ok(()) => Ok(Published {
                destination,
                bytes,
                cleanup: cleanup(directory),
            }),
            Err(error) => {
                let cause = error.error;
                drop(error.path);
                Err(failed(
                    anyhow::Error::new(cause)
                        .context("Cannot save without overwriting; choose a new destination"),
                    cleanup(directory),
                ))
            }
        }
    }
}

#[cfg(test)]
mod tests;