cargo-allow 0.1.9

Source-tree exception ledger and policy scanner for Rust repositories.
use allow_core::{CargoAllowError, CargoAllowResult};
use std::fs;
use std::path::Path;

pub(crate) fn emit_text(output: Option<&Path>, contents: &str) -> CargoAllowResult<()> {
    if let Some(path) = output {
        write_file(path, contents)?;
    } else {
        println!("{contents}");
    }
    Ok(())
}

pub(crate) fn emit_stderr_text(output: Option<&Path>, contents: &str) -> CargoAllowResult<()> {
    if let Some(path) = output {
        write_file(path, contents)?;
    } else {
        eprintln!("{contents}");
    }
    Ok(())
}

pub(crate) fn write_file(path: impl AsRef<Path>, contents: &str) -> CargoAllowResult<()> {
    let path = path.as_ref();
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|e| {
            CargoAllowError::new(format!("failed to create {}: {e}", parent.display()))
        })?;
    }
    fs::write(path, contents)
        .map_err(|e| CargoAllowError::new(format!("failed to write {}: {e}", path.display())))
}

pub(crate) fn write_file_no_overwrite(
    path: impl AsRef<Path>,
    contents: &str,
    force: bool,
) -> CargoAllowResult<()> {
    let path = path.as_ref();
    if path.exists() && !force {
        return Err(CargoAllowError::new(format!(
            "{} already exists; use --force to overwrite",
            path.display()
        )));
    }
    write_file(path, contents)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::{Path, PathBuf};

    #[test]
    fn emit_text_writes_to_output_path() -> Result<(), Box<dyn std::error::Error>> {
        let root = TempRoot::new("emit-text")?;
        let output = root.path().join("nested/report.txt");

        let result = emit_text(Some(&output), "hello report\n");

        assert!(result.is_ok());
        assert_eq!(fs::read_to_string(&output)?, "hello report\n");
        Ok(())
    }

    #[test]
    fn emit_stderr_text_writes_to_output_path() -> Result<(), Box<dyn std::error::Error>> {
        let root = TempRoot::new("emit-stderr-text")?;
        let output = root.path().join("nested/summary.txt");

        let result = emit_stderr_text(Some(&output), "summary\n");

        assert!(result.is_ok());
        assert_eq!(fs::read_to_string(&output)?, "summary\n");
        Ok(())
    }

    #[test]
    fn write_file_reports_parent_creation_errors() -> Result<(), Box<dyn std::error::Error>> {
        let root = TempRoot::new("write-parent-error")?;
        let file_parent = root.path().join("not-a-directory");
        fs::write(&file_parent, "already a file")?;
        let output = file_parent.join("report.txt");
        let source_error = fs::create_dir_all(&file_parent)
            .expect_err("creating a directory over a file should fail");

        let err = write_file(&output, "contents").expect_err("parent creation should fail");
        let message = err.to_string();

        assert!(message.contains("failed to create"));
        assert!(message.contains(&file_parent.display().to_string()));
        assert_eq!(
            err,
            CargoAllowError::new(format!(
                "failed to create {}: {}",
                file_parent.display(),
                source_error
            ))
        );
        Ok(())
    }

    #[test]
    fn write_file_reports_file_write_errors() -> Result<(), Box<dyn std::error::Error>> {
        let root = TempRoot::new("write-file-error")?;
        let output = root.path().join("directory-target");
        fs::create_dir_all(&output)?;
        let source_error =
            fs::write(&output, "contents").expect_err("writing to a directory should fail");

        let err = write_file(&output, "contents").expect_err("writing to a directory should fail");
        let message = err.to_string();

        assert!(message.contains("failed to write"));
        assert!(message.contains(&output.display().to_string()));
        assert_eq!(
            err,
            CargoAllowError::new(format!(
                "failed to write {}: {}",
                output.display(),
                source_error
            ))
        );
        Ok(())
    }

    #[test]
    fn write_file_no_overwrite_rejects_existing_path_without_force()
    -> Result<(), Box<dyn std::error::Error>> {
        let root = TempRoot::new("no-overwrite")?;
        let output = root.path().join("policy/allow.toml");
        write_file(&output, "original")?;

        let err = write_file_no_overwrite(&output, "replacement", false)
            .expect_err("existing file should require force");

        assert!(err.to_string().contains("already exists"));
        assert_eq!(fs::read_to_string(&output)?, "original");
        Ok(())
    }

    #[test]
    fn write_file_no_overwrite_replaces_existing_path_with_force()
    -> Result<(), Box<dyn std::error::Error>> {
        let root = TempRoot::new("force-overwrite")?;
        let output = root.path().join("policy/allow.toml");
        write_file(&output, "original")?;

        let result = write_file_no_overwrite(&output, "replacement", true);

        assert!(result.is_ok());
        assert_eq!(fs::read_to_string(&output)?, "replacement");
        Ok(())
    }

    struct TempRoot {
        path: PathBuf,
    }

    impl TempRoot {
        fn new(label: &str) -> std::io::Result<Self> {
            let unique = format!(
                "cargo-allow-io-{label}-{}-{}",
                std::process::id(),
                std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .unwrap_or_else(|err| {
                        std::panic::panic_any(format!("system time before epoch: {err}"))
                    })
                    .as_nanos()
            );
            let path = std::env::temp_dir().join(unique);
            let _ = fs::remove_dir_all(&path);
            fs::create_dir_all(&path)?;
            Ok(Self { path })
        }

        fn path(&self) -> &Path {
            &self.path
        }
    }

    impl Drop for TempRoot {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.path);
        }
    }
}