geam-cli 0.2.3

Standalone command implementation for Geam
Documentation
use crate::error::CliError;
use camino::Utf8Path;
use std::fs;
use std::io::Write;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum SyncOutcome {
    Unchanged,
    Updated,
}

pub(super) fn validate_generated(destination: &Utf8Path) -> Result<(), CliError> {
    match fs::read(destination) {
        Ok(current) if current.starts_with(super::GENERATED_HEADER.as_bytes()) => Ok(()),
        Ok(_) => Err(CliError::EmbeddingFileConflict {
            path: destination.to_path_buf(),
            reason: "the file was not generated by geam embedding sync".to_owned(),
        }),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(error) => Err(CliError::FileRead {
            path: destination.to_path_buf(),
            error,
        }),
    }
}

pub(super) fn check(
    manifest: &Utf8Path,
    destination: &Utf8Path,
    expected: &[u8],
) -> Result<(), CliError> {
    match fs::read(destination) {
        Ok(current) if current == expected => Ok(()),
        Ok(_) => Err(out_of_date(manifest, destination)),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            Err(out_of_date(manifest, destination))
        }
        Err(error) => Err(CliError::FileRead {
            path: destination.to_path_buf(),
            error,
        }),
    }
}

pub(super) fn sync(
    directory: &Utf8Path,
    destination: &Utf8Path,
    expected: &[u8],
) -> Result<SyncOutcome, CliError> {
    match fs::read(destination) {
        Ok(current) if current == expected => return Ok(SyncOutcome::Unchanged),
        Ok(_) => {}
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
        Err(error) => {
            return Err(CliError::FileRead {
                path: destination.to_path_buf(),
                error,
            });
        }
    }

    let mut temporary =
        tempfile::NamedTempFile::new_in(directory).map_err(|error| CliError::FileWrite {
            path: destination.to_path_buf(),
            error,
        })?;
    write_expected(&mut temporary, destination, expected).and_then(|()| {
        temporary
            .persist(destination)
            .map(|_| ())
            .map_err(|error| CliError::FileWrite {
                path: destination.to_path_buf(),
                error: error.error,
            })
    })?;
    Ok(SyncOutcome::Updated)
}

fn write_expected(
    writer: &mut dyn Write,
    destination: &Utf8Path,
    expected: &[u8],
) -> Result<(), CliError> {
    writer
        .write_all(expected)
        .and_then(|()| writer.flush())
        .map_err(|error| CliError::FileWrite {
            path: destination.to_path_buf(),
            error,
        })
}

fn out_of_date(manifest: &Utf8Path, destination: &Utf8Path) -> CliError {
    CliError::EmbeddingBindingsOutOfDate {
        manifest: manifest.to_path_buf(),
        output: destination.to_path_buf(),
    }
}

#[cfg(test)]
mod tests {
    use super::{SyncOutcome, check, sync, validate_generated, write_expected};
    use crate::error::CliError;
    use camino::Utf8PathBuf;
    use std::fs;
    use std::io::{self, Write};
    use tempfile::tempdir;

    #[test]
    fn permits_only_missing_or_marked_generated_files_for_replacement() {
        let directory = tempdir().expect("temporary directory");
        let root = Utf8PathBuf::from_path_buf(directory.path().to_owned()).expect("UTF-8 fixture");
        let destination = root.join("geam_bindings.rs");
        validate_generated(&destination).expect("missing generated file");
        fs::write(&destination, "// handwritten Rust\n").expect("user module");
        assert_eq!(
            validate_generated(&destination)
                .expect_err("user-owned module")
                .to_string(),
            format!(
                "refusing to replace existing embedding file {destination}: the file was not generated by geam embedding sync"
            )
        );
        fs::write(
            &destination,
            "// Generated by `geam embedding sync`. Do not edit.\n\n// old bindings\n",
        )
        .expect("generated module");
        validate_generated(&destination).expect("marked generated module");
        let unreadable = root.join("directory");
        fs::create_dir(&unreadable).expect("conflicting directory");
        assert!(
            matches!(validate_generated(&unreadable), Err(CliError::FileRead { path, .. }) if path == unreadable)
        );
    }

    #[test]
    fn leaves_identical_output_untouched_and_atomically_replaces_changes() {
        let directory = tempdir().expect("temporary directory should be created");
        let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf())
            .expect("temporary path should be valid UTF-8");
        let destination = root.join("geam_bindings.rs");
        fs::write(&destination, "same").expect("fixture output should be written");
        #[cfg(unix)]
        let original_inode = std::os::unix::fs::MetadataExt::ino(
            &fs::metadata(&destination).expect("fixture metadata should be readable"),
        );

        assert_eq!(
            sync(&root, &destination, b"same").expect("identical output should succeed"),
            SyncOutcome::Unchanged,
        );
        #[cfg(unix)]
        assert_eq!(
            std::os::unix::fs::MetadataExt::ino(
                &fs::metadata(&destination).expect("unchanged metadata should be readable"),
            ),
            original_inode,
        );

        assert_eq!(
            sync(&root, &destination, b"changed").expect("changed output should succeed"),
            SyncOutcome::Updated,
        );
        assert_eq!(
            fs::read(&destination).expect("updated output should be readable"),
            b"changed",
        );
        #[cfg(unix)]
        assert_ne!(
            std::os::unix::fs::MetadataExt::ino(
                &fs::metadata(&destination).expect("updated metadata should be readable"),
            ),
            original_inode,
        );
    }

    #[test]
    fn checks_exact_missing_and_stale_bytes_without_writing() {
        let directory = tempdir().expect("temporary directory should be created");
        let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf())
            .expect("temporary path should be valid UTF-8");
        let manifest = root.join("Cargo.toml");
        let destination = root.join("geam_bindings.rs");

        let missing = check(&manifest, &destination, b"expected")
            .expect_err("missing output should fail checking");
        assert!(matches!(
            missing,
            CliError::EmbeddingBindingsOutOfDate { manifest: path, output }
                if path == manifest && output == destination
        ));
        assert!(!destination.exists());

        fs::write(&destination, b"stale\r\n").expect("stale output should be written");
        let stale_metadata = fs::metadata(&destination).expect("stale metadata should be readable");
        let stale = check(&manifest, &destination, b"expected\n")
            .expect_err("byte-stale output should fail checking");
        assert!(matches!(
            stale,
            CliError::EmbeddingBindingsOutOfDate { manifest: path, output }
                if path == manifest && output == destination
        ));
        assert_eq!(
            fs::read(&destination).expect("stale output should remain readable"),
            b"stale\r\n",
        );
        assert_eq!(
            fs::metadata(&destination)
                .expect("checked metadata should be readable")
                .permissions(),
            stale_metadata.permissions(),
        );

        fs::write(&destination, b"expected\n").expect("exact output should be written");
        let exact_metadata = fs::metadata(&destination).expect("exact metadata should be readable");
        let modified = exact_metadata
            .modified()
            .expect("exact modification time should be readable");
        #[cfg(unix)]
        let inode = std::os::unix::fs::MetadataExt::ino(&exact_metadata);
        check(&manifest, &destination, b"expected\n").expect("exact output should pass checking");
        let checked_metadata =
            fs::metadata(&destination).expect("checked exact metadata should be readable");
        assert_eq!(
            fs::read(&destination).expect("checked exact output should remain readable"),
            b"expected\n",
        );
        assert_eq!(checked_metadata.permissions(), exact_metadata.permissions());
        assert_eq!(
            checked_metadata
                .modified()
                .expect("checked modification time should be readable"),
            modified,
        );
        #[cfg(unix)]
        assert_eq!(
            std::os::unix::fs::MetadataExt::ino(&checked_metadata),
            inode,
        );
    }

    #[test]
    fn preserves_check_read_failures_with_destination_context() {
        let directory = tempdir().expect("temporary directory should be created");
        let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf())
            .expect("temporary path should be valid UTF-8");
        let manifest = root.join("Cargo.toml");
        let destination = root.join("geam_bindings.rs");
        fs::create_dir(&destination).expect("directory fixture should be created");

        let error = check(&manifest, &destination, b"expected")
            .expect_err("unreadable checked output should fail");
        assert!(matches!(
            error,
            CliError::FileRead { path, .. } if path == destination
        ));
        assert!(destination.is_dir());
    }

    #[test]
    fn preserves_read_failures_with_destination_context() {
        let directory = tempdir().expect("temporary directory should be created");
        let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf())
            .expect("temporary path should be valid UTF-8");
        let destination = root.join("geam_bindings.rs");
        fs::create_dir(&destination).expect("directory fixture should be created");

        let error =
            sync(&root, &destination, b"expected").expect_err("unreadable destination should fail");
        assert!(matches!(
            error,
            CliError::FileRead { path, .. } if path == destination
        ));
        assert!(destination.is_dir());
    }

    #[test]
    fn preserves_write_failures_with_destination_context() {
        let directory = tempdir().expect("temporary directory should be created");
        let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf())
            .expect("temporary path should be valid UTF-8");
        let missing_directory = root.join("missing");
        let destination = missing_directory.join("geam_bindings.rs");

        let error = sync(&missing_directory, &destination, b"expected")
            .expect_err("missing output directory should fail");
        assert!(matches!(
            error,
            CliError::FileWrite { path, error }
                if path == destination && error.kind() == std::io::ErrorKind::NotFound
        ));
        assert!(!destination.exists());
    }

    #[test]
    fn preserves_content_write_and_flush_failures_with_destination_context() {
        enum Failure {
            Write,
            Flush,
        }

        struct FailingWriter(Failure);

        impl Write for FailingWriter {
            fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
                match self.0 {
                    Failure::Write => Err(io::Error::other("fixture write failure")),
                    Failure::Flush => Ok(buffer.len()),
                }
            }

            fn flush(&mut self) -> io::Result<()> {
                Err(io::Error::other("fixture flush failure"))
            }
        }

        let destination = Utf8PathBuf::from("/workspace/src/geam_bindings.rs");
        for failure in [Failure::Write, Failure::Flush] {
            let error = write_expected(&mut FailingWriter(failure), &destination, b"expected")
                .expect_err("content output failure should be preserved");
            assert!(matches!(
                error,
                CliError::FileWrite { path, error }
                    if path == destination && error.kind() == io::ErrorKind::Other
            ));
        }
    }

    #[test]
    fn preserves_atomic_persist_failures_with_destination_context() {
        let directory = tempdir().expect("temporary directory should be created");
        let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf())
            .expect("temporary path should be valid UTF-8");
        let destination = root.join("missing/geam_bindings.rs");

        let error = sync(&root, &destination, b"expected")
            .expect_err("missing destination parent should reject persistence");
        assert!(matches!(
            error,
            CliError::FileWrite { path, error }
                if path == destination && error.kind() == io::ErrorKind::NotFound
        ));
        assert!(!destination.exists());
    }

    #[cfg(unix)]
    #[test]
    fn preserves_previous_output_when_atomic_replacement_cannot_start() {
        use std::os::unix::fs::PermissionsExt;

        let directory = tempdir().expect("temporary directory should be created");
        let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf())
            .expect("temporary path should be valid UTF-8");
        let destination = root.join("geam_bindings.rs");
        fs::write(&destination, "previous").expect("previous output should be written");
        fs::set_permissions(&root, fs::Permissions::from_mode(0o500))
            .expect("output directory should become read-only");

        let result = sync(&root, &destination, b"changed");
        fs::set_permissions(&root, fs::Permissions::from_mode(0o700))
            .expect("output directory permissions should be restored");

        let error = result.expect_err("read-only output directory should reject replacement");
        assert!(matches!(
            error,
            CliError::FileWrite { path, error }
                if path == destination
                    && error.kind() == std::io::ErrorKind::PermissionDenied
        ));
        assert_eq!(
            fs::read(&destination).expect("previous output should remain readable"),
            b"previous",
        );
    }
}