camber-cli 0.2.0

CLI, project scaffolding, and config-driven proxy for Camber
Documentation
use std::io::Write;
use std::path::{Path, PathBuf};

use crate::support::FixtureError;

const RUN_ID_ENVIRONMENT: &str = "CAMBER_EXTERNAL_RUN_ID";
const CLEANUP_WITNESS_ENVIRONMENT: &str = "CAMBER_EXTERNAL_CLEANUP_WITNESS";
const MAX_RUN_ID_BYTES: usize = 64;

pub struct ExternalRun {
    run_id: Box<str>,
}

impl ExternalRun {
    pub fn from_environment() -> Result<Self, FixtureError> {
        let run_id = environment(RUN_ID_ENVIRONMENT)?;
        Self::parse(&run_id)
    }

    pub fn parse(run_id: &str) -> Result<Self, FixtureError> {
        let valid_length = !run_id.is_empty() && run_id.len() <= MAX_RUN_ID_BYTES;
        let valid_alphabet = run_id
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));

        match (valid_length, valid_alphabet) {
            (true, true) => Ok(Self {
                run_id: run_id.into(),
            }),
            _ => Err(FixtureError::new(
                "CAMBER_EXTERNAL_RUN_ID must contain 1-64 URL-safe ASCII characters",
            )),
        }
    }

    pub fn run_id(&self) -> &str {
        &self.run_id
    }

    pub fn compile_project_name(&self) -> Box<str> {
        format!("camber-external-compile-{}", self.run_id).into_boxed_str()
    }

    pub fn docker_image_tag(&self) -> Box<str> {
        format!("camber-external-cli:{}", self.run_id).into_boxed_str()
    }
}

pub trait CleanupCompletion {
    fn emit(&mut self, run_id: &str, resource: &str) -> Result<(), FixtureError>;
}

pub struct CleanupWitness {
    path: PathBuf,
}

impl CleanupWitness {
    pub fn from_environment() -> Result<Self, FixtureError> {
        let configured = environment(CLEANUP_WITNESS_ENVIRONMENT)?;
        let path = PathBuf::from(configured);
        validate_witness_path(&path)?;
        Ok(Self { path })
    }
}

impl CleanupCompletion for CleanupWitness {
    fn emit(&mut self, run_id: &str, resource: &str) -> Result<(), FixtureError> {
        validate_witness_value(run_id, "run ID")?;
        validate_witness_value(resource, "resource identity")?;

        let document = format!(
            "{{\"run_id\":\"{run_id}\",\"resources\":[\"{resource}\"],\"cleanup_status\":\"completed\"}}\n"
        );
        let mut file = std::fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(&self.path)?;
        file.write_all(document.as_bytes())?;
        file.sync_all()?;
        Ok(())
    }
}

pub fn close_temp_dir_and_emit<W>(
    temp_dir: tempfile::TempDir,
    mut witness: W,
    run: &ExternalRun,
    resource: &str,
) -> Result<(), FixtureError>
where
    W: CleanupCompletion,
{
    let removed_path = temp_dir.path().to_path_buf();
    temp_dir.close()?;

    match removed_path.try_exists()? {
        false => witness.emit(run.run_id(), resource),
        true => Err(FixtureError::new(format!(
            "temporary project still exists after cleanup: {}",
            removed_path.display()
        ))),
    }
}

fn environment(variable: &'static str) -> Result<String, FixtureError> {
    std::env::var(variable).map_err(|error| {
        FixtureError::new(format!(
            "{variable} must be set to valid Unicode for a selected external test: {error}"
        ))
    })
}

fn validate_witness_path(path: &Path) -> Result<(), FixtureError> {
    let parent_is_directory = path.parent().is_some_and(Path::is_dir);
    let has_file_name = path.file_name().is_some_and(|name| !name.is_empty());
    let destination_is_absent = !path.try_exists()?;

    match (
        path.is_absolute(),
        parent_is_directory,
        has_file_name,
        destination_is_absent,
    ) {
        (true, true, true, true) => Ok(()),
        _ => Err(FixtureError::new(
            "CAMBER_EXTERNAL_CLEANUP_WITNESS must be an unused absolute file path in an existing directory",
        )),
    }
}

fn validate_witness_value(value: &str, description: &str) -> Result<(), FixtureError> {
    let valid = !value.is_empty()
        && value
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':' | b'.'));

    match valid {
        true => Ok(()),
        false => Err(FixtureError::new(format!(
            "cleanup witness {description} contains unsafe characters"
        ))),
    }
}