geam-cli 0.2.2

Standalone command implementation for Geam
Documentation
use crate::error::CliError;
use crate::process::{run_checked_with_progress, run_inherited};
use crate::progress::Progress;
use camino::{Utf8Path, Utf8PathBuf};
use std::fs;
use std::process::{Command, Stdio};

const TARGET_DIRECTORY: &str = "build/geam/target";

pub(crate) trait CargoLock {
    fn generate_lockfile(
        &self,
        project_root: &Utf8Path,
        progress: &mut Progress<'_>,
    ) -> Result<(), CliError>;
}

pub(crate) trait RunnerChecker {
    fn check(
        &self,
        project_root: &Utf8Path,
        module: &str,
        progress: &mut Progress<'_>,
    ) -> Result<(), CliError>;
}

pub(crate) trait RunnerExecutor {
    fn execute(
        &self,
        project_root: &Utf8Path,
        module: &str,
        configurations: &[(String, Utf8PathBuf)],
    ) -> Result<(), CliError>;
}

pub(crate) struct SystemCargo;

impl CargoLock for SystemCargo {
    fn generate_lockfile(
        &self,
        project_root: &Utf8Path,
        progress: &mut Progress<'_>,
    ) -> Result<(), CliError> {
        finish_process(run_checked_with_progress(
            Command::new("cargo")
                .arg("generate-lockfile")
                .arg("--manifest-path")
                .arg(project_root.join("Cargo.toml"))
                .current_dir(project_root)
                .env("CARGO_TARGET_DIR", project_root.join(TARGET_DIRECTORY)),
            progress,
            Stdio::inherit(),
        ))
    }
}

impl RunnerChecker for SystemCargo {
    fn check(
        &self,
        project_root: &Utf8Path,
        module: &str,
        progress: &mut Progress<'_>,
    ) -> Result<(), CliError> {
        finish_process(run_checked_with_progress(
            &mut runner_command(project_root, "check", module),
            progress,
            Stdio::inherit(),
        ))
    }
}

impl RunnerExecutor for SystemCargo {
    fn execute(
        &self,
        project_root: &Utf8Path,
        module: &str,
        configurations: &[(String, Utf8PathBuf)],
    ) -> Result<(), CliError> {
        run_inherited(&mut execution_command(project_root, module, configurations))
    }
}

fn finish_process(result: Result<std::process::Output, CliError>) -> Result<(), CliError> {
    result.map(drop)
}

fn runner_command(project_root: &Utf8Path, mode: &str, module: &str) -> Command {
    let mut command = Command::new("cargo");
    command
        .arg("run")
        .arg("--locked")
        .arg("--bin")
        .arg("geam-runner")
        .arg("--")
        .arg(mode)
        .arg(project_root)
        .arg(module)
        .current_dir(project_root)
        .env("CARGO_TARGET_DIR", project_root.join(TARGET_DIRECTORY));
    command
}

fn execution_command(
    project_root: &Utf8Path,
    module: &str,
    configurations: &[(String, Utf8PathBuf)],
) -> Command {
    let mut command = runner_command(project_root, "run", module);
    for (package, path) in configurations {
        command.arg(format!("{package}={path}"));
    }
    command
}

pub(crate) fn reconcile_lock(
    project_root: &Utf8Path,
    manifest_changed: bool,
    cargo: &dyn CargoLock,
    progress: &mut Progress<'_>,
) -> Result<(), CliError> {
    let lock = project_root.join("Cargo.lock");
    if manifest_changed {
        remove_stale_lock(&lock)?;
    }
    if manifest_changed || !lock.is_file() {
        progress.report(format_args!(
            "Resolving Cargo dependencies in {project_root}"
        ))?;
        cargo.generate_lockfile(project_root, progress)?;
    }
    Ok(())
}

fn remove_stale_lock(path: &Utf8Path) -> Result<(), CliError> {
    match fs::remove_file(path) {
        Ok(()) => Ok(()),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(error) => Err(CliError::FileWrite {
            path: path.to_path_buf(),
            error,
        }),
    }
}

#[cfg(test)]
mod tests {
    use super::{
        CargoLock, RunnerChecker, SystemCargo, execution_command, reconcile_lock, runner_command,
    };
    use crate::error::CliError;
    use crate::progress::Progress;
    use camino::{Utf8Path, Utf8PathBuf};
    use std::cell::RefCell;
    use std::fs;
    use tempfile::tempdir;

    #[derive(Default)]
    struct RecordingCargo {
        operations: RefCell<Vec<String>>,
    }

    impl CargoLock for RecordingCargo {
        fn generate_lockfile(
            &self,
            project_root: &Utf8Path,
            _progress: &mut Progress<'_>,
        ) -> Result<(), CliError> {
            self.operations.borrow_mut().push("lock".to_owned());
            fs::write(project_root.join("Cargo.lock"), "fixture lock\n")
                .expect("fixture lock should be written");
            Ok(())
        }
    }

    struct FailingCargo;

    impl CargoLock for FailingCargo {
        fn generate_lockfile(
            &self,
            _project_root: &Utf8Path,
            _progress: &mut Progress<'_>,
        ) -> Result<(), CliError> {
            Err(CliError::ProcessFailure {
                command: "cargo generate-lockfile".to_owned(),
                status: Some(1),
                stderr: "fixture stop".to_owned(),
            })
        }
    }

    #[test]
    fn constructs_check_and_run_commands_with_project_owned_targets() {
        let project = tempdir().expect("temporary project should be created");
        let root = Utf8PathBuf::from_path_buf(project.path().to_path_buf())
            .expect("temporary path should be valid UTF-8");

        let check = runner_command(&root, "check", "application");
        assert_eq!(
            check
                .get_args()
                .map(|argument| argument.to_string_lossy().into_owned())
                .collect::<Vec<_>>(),
            [
                "run",
                "--locked",
                "--bin",
                "geam-runner",
                "--",
                "check",
                root.as_str(),
                "application",
            ],
        );

        let run = execution_command(
            &root,
            "worker",
            &[("images".to_owned(), root.join("config.toml"))],
        );
        assert_eq!(
            run.get_args()
                .map(|argument| argument.to_string_lossy().into_owned())
                .collect::<Vec<_>>(),
            [
                "run".to_owned(),
                "--locked".to_owned(),
                "--bin".to_owned(),
                "geam-runner".to_owned(),
                "--".to_owned(),
                "run".to_owned(),
                root.to_string(),
                "worker".to_owned(),
                format!("images={}", root.join("config.toml")),
            ],
        );
        assert!(run.get_envs().any(|(key, value)| {
            key == "CARGO_TARGET_DIR" && value == Some(root.join("build/geam/target").as_os_str())
        }),);
    }

    #[test]
    fn updates_only_changed_or_missing_locks() {
        let project = tempdir().expect("temporary project should be created");
        let root = Utf8PathBuf::from_path_buf(project.path().to_path_buf())
            .expect("temporary path should be valid UTF-8");
        let cargo = RecordingCargo::default();
        let mut output = Vec::new();

        reconcile_lock(&root, true, &cargo, &mut Progress::Visible(&mut output))
            .expect("initial lock should reconcile");
        assert_eq!(cargo.operations.borrow().as_slice(), ["lock"]);
        assert_eq!(
            output,
            format!("geam: Resolving Cargo dependencies in {root}\n").as_bytes()
        );

        cargo.operations.borrow_mut().clear();
        output.clear();
        reconcile_lock(&root, false, &cargo, &mut Progress::Visible(&mut output))
            .expect("unchanged lock should reconcile");
        assert!(cargo.operations.borrow().is_empty());
        assert!(output.is_empty());

        fs::remove_file(root.join("Cargo.lock")).expect("fixture lock should be removed");
        reconcile_lock(&root, false, &cargo, &mut Progress::Visible(&mut output))
            .expect("missing lock should be regenerated");
        assert_eq!(cargo.operations.borrow().as_slice(), ["lock"]);
        assert_eq!(
            output,
            format!("geam: Resolving Cargo dependencies in {root}\n").as_bytes()
        );
    }

    #[test]
    fn failed_progress_stops_before_generating_a_lock() {
        let project = tempdir().expect("temporary project should be created");
        let root = Utf8PathBuf::from_path_buf(project.path().to_path_buf())
            .expect("temporary path should be valid UTF-8");
        let cargo = RecordingCargo::default();
        let path = root.join("output");
        fs::write(&path, "read-only\n").expect("output fixture");
        let mut output = fs::File::open(&path).expect("read-only output");
        let error = reconcile_lock(&root, false, &cargo, &mut Progress::Visible(&mut output))
            .expect_err("progress failure should prevent Cargo execution");
        assert_eq!(error.to_string(), "failed to write preparation progress");
        assert!(cargo.operations.borrow().is_empty());
        assert!(!root.join("Cargo.lock").exists());
        assert_eq!(
            fs::read(path).expect("unchanged output fixture"),
            b"read-only\n"
        );
    }

    #[test]
    fn preserves_lock_lifecycle_failures() {
        let failure = tempdir().expect("temporary project should be created");
        let root = Utf8PathBuf::from_path_buf(failure.path().to_path_buf())
            .expect("temporary path should be valid UTF-8");
        fs::write(root.join("Cargo.lock"), "stale lock\n")
            .expect("stale fixture lock should be written");
        let error = reconcile_lock(&root, true, &FailingCargo, &mut Progress::Hidden)
            .expect_err("lock failure should be preserved");
        assert!(matches!(
            error,
            CliError::ProcessFailure { command, status: Some(1), stderr }
                if command == "cargo generate-lockfile" && stderr == "fixture stop"
        ));
        assert!(
            !root.join("Cargo.lock").exists(),
            "failed lock generation must leave the next reconciliation recoverable",
        );

        fs::create_dir(root.join("Cargo.lock")).expect("blocking lock directory should be created");
        let lock = root.join("Cargo.lock");
        let expected_kind = fs::remove_file(&lock)
            .expect_err("directory should reject file removal")
            .kind();
        let error = reconcile_lock(&root, true, &FailingCargo, &mut Progress::Hidden)
            .expect_err("an unremovable stale lock should fail before Cargo");
        assert!(matches!(
            error,
            CliError::FileWrite { path, error }
                if path == lock && error.kind() == expected_kind
        ));
    }

    #[test]
    fn preserves_system_cargo_process_failures() {
        let project = tempdir().expect("temporary project should be created");
        let root = Utf8PathBuf::from_path_buf(project.path().to_path_buf())
            .expect("temporary path should be valid UTF-8");

        let generation = SystemCargo
            .generate_lockfile(&root, &mut Progress::Hidden)
            .expect_err("missing manifest should reject lock generation");
        assert!(matches!(
            generation,
            CliError::ProcessFailure { command, status: Some(101), stderr }
                if command
                    == format!(
                        "cargo generate-lockfile --manifest-path {}",
                        root.join("Cargo.toml")
                    )
                    && stderr.contains("manifest path")
        ));

        let check = SystemCargo
            .check(&root, "application", &mut Progress::Hidden)
            .expect_err("missing manifest should reject runner checking");
        assert!(matches!(
            check,
            CliError::ProcessFailure { command, status: Some(101), stderr }
                if command
                    == format!(
                        "cargo run --locked --bin geam-runner -- check {root} application"
                    )
                    && stderr.contains("could not find `Cargo.toml`")
        ));
    }
}