robotrt-cli 0.1.0-beta.2

RobotRT modular robotics runtime and middleware components.
use super::*;

pub(super) fn load_resolved_config(
    opts: &CliOrchestrateOptions,
) -> Result<OrchestrationConfig, String> {
    let mut config = read_orchestration_config(&opts.config_path)?;

    if let Some(profile_name) = &opts.profile {
        apply_profile(&mut config, profile_name)?;
    }

    for overlay_file in &opts.overlay_files {
        apply_overlay_file(&mut config, overlay_file)?;
    }

    Ok(config)
}

fn read_orchestration_config(path: &PathBuf) -> Result<OrchestrationConfig, String> {
    let content = std::fs::read_to_string(path)
        .map_err(|err| format!("read orchestrate config {} failed: {err}", path.display()))?;
    serde_json::from_str::<OrchestrationConfig>(&content)
        .map_err(|err| format!("parse orchestrate config {} failed: {err}", path.display()))
}

fn apply_profile(config: &mut OrchestrationConfig, profile_name: &str) -> Result<(), String> {
    let profile = config
        .profiles
        .get(profile_name)
        .cloned()
        .ok_or_else(|| format!("profile not found: {profile_name}"))?;

    if let Some(max_parallel) = profile.max_parallel {
        config.max_parallel = max_parallel;
    }

    for task in &mut config.tasks {
        if !profile.env.is_empty() {
            for (key, value) in &profile.env {
                task.env.entry(key.clone()).or_insert_with(|| value.clone());
            }
        }

        if let Some(override_item) = profile.tasks.get(&task.name) {
            apply_task_override(task, override_item);
        }
    }

    Ok(())
}

fn apply_overlay_file(config: &mut OrchestrationConfig, overlay_file: &Path) -> Result<(), String> {
    let content = std::fs::read_to_string(overlay_file)
        .map_err(|err| format!("read overlay {} failed: {err}", overlay_file.display()))?;
    let overlay = serde_json::from_str::<OrchestrationOverlay>(&content)
        .map_err(|err| format!("parse overlay {} failed: {err}", overlay_file.display()))?;

    if let Some(project) = overlay.project {
        config.project = project;
    }
    if let Some(max_parallel) = overlay.max_parallel {
        config.max_parallel = max_parallel;
    }

    for task in &mut config.tasks {
        for (key, value) in &overlay.env {
            task.env.entry(key.clone()).or_insert_with(|| value.clone());
        }

        if let Some(override_item) = overlay.tasks.get(&task.name) {
            apply_task_override(task, override_item);
        }
    }

    for task in overlay.add_tasks {
        config.tasks.push(task);
    }

    Ok(())
}

fn apply_task_override(task: &mut OrchestrationTask, override_item: &TaskOverride) {
    if let Some(command) = &override_item.command {
        task.command = command.clone();
    }
    if let Some(depends_on) = &override_item.depends_on {
        task.depends_on = depends_on.clone();
    }
    if let Some(cwd) = &override_item.cwd {
        task.cwd = Some(cwd.clone());
    }
    if !override_item.env.is_empty() {
        for (key, value) in &override_item.env {
            task.env.insert(key.clone(), value.clone());
        }
    }
    if let Some(continue_on_error) = override_item.continue_on_error {
        task.continue_on_error = continue_on_error;
    }
    if let Some(enabled) = override_item.enabled {
        task.enabled = enabled;
    }
    if let Some(group) = &override_item.group {
        task.group = Some(group.clone());
    }
    if let Some(run_mode) = &override_item.run_mode {
        task.run_mode = run_mode.clone();
    }
}

pub(super) fn default_orchestration_config() -> OrchestrationConfig {
    let mut profiles = BTreeMap::new();
    profiles.insert(
        String::from("ci"),
        OrchestrationProfile {
            description: Some(String::from("CI gate profile")),
            max_parallel: Some(2),
            env: BTreeMap::new(),
            tasks: BTreeMap::new(),
        },
    );

    OrchestrationConfig {
        api_version: String::from(ORCHESTRATION_CONFIG_API_VERSION),
        project: String::from("robotrt"),
        max_parallel: 1,
        profiles,
        tasks: vec![
            OrchestrationTask {
                name: String::from("gate-fast-validated"),
                command: vec![
                    String::from("bash"),
                    String::from("scripts/gate-fast-validated.sh"),
                ],
                depends_on: Vec::new(),
                cwd: None,
                env: BTreeMap::new(),
                continue_on_error: false,
                enabled: true,
                group: Some(String::from("gate")),
                run_mode: TaskRunMode::Oneshot,
            },
            OrchestrationTask {
                name: String::from("compat-matrix"),
                command: vec![
                    String::from("bash"),
                    String::from("scripts/compat-matrix-gate.sh"),
                ],
                depends_on: vec![String::from("gate-fast-validated")],
                cwd: None,
                env: BTreeMap::new(),
                continue_on_error: false,
                enabled: true,
                group: Some(String::from("gate")),
                run_mode: TaskRunMode::Oneshot,
            },
            OrchestrationTask {
                name: String::from("slo-gate"),
                command: vec![String::from("bash"), String::from("scripts/slo-gate.sh")],
                depends_on: vec![String::from("gate-fast-validated")],
                cwd: None,
                env: BTreeMap::new(),
                continue_on_error: false,
                enabled: true,
                group: Some(String::from("gate")),
                run_mode: TaskRunMode::Oneshot,
            },
            OrchestrationTask {
                name: String::from("obs-export-check"),
                command: vec![
                    String::from("bash"),
                    String::from("scripts/obs-export-check.sh"),
                ],
                depends_on: vec![String::from("gate-fast-validated")],
                cwd: None,
                env: BTreeMap::new(),
                continue_on_error: false,
                enabled: true,
                group: Some(String::from("gate")),
                run_mode: TaskRunMode::Oneshot,
            },
            OrchestrationTask {
                name: String::from("release-candidate"),
                command: vec![
                    String::from("bash"),
                    String::from("scripts/release-candidate.sh"),
                ],
                depends_on: vec![
                    String::from("compat-matrix"),
                    String::from("slo-gate"),
                    String::from("obs-export-check"),
                ],
                cwd: None,
                env: BTreeMap::new(),
                continue_on_error: false,
                enabled: true,
                group: Some(String::from("release")),
                run_mode: TaskRunMode::Oneshot,
            },
        ],
    }
}

pub(super) fn validate_config(config: &OrchestrationConfig) -> Vec<String> {
    let mut errors = Vec::new();

    if config.api_version != ORCHESTRATION_CONFIG_API_VERSION {
        errors.push(format!(
            "api_version must be {}",
            ORCHESTRATION_CONFIG_API_VERSION
        ));
    }

    if config.max_parallel == 0 {
        errors.push(String::from("max_parallel must be >= 1"));
    }

    if config.tasks.is_empty() {
        errors.push(String::from("tasks must not be empty"));
        return errors;
    }

    let mut names = HashSet::new();
    for task in &config.tasks {
        if task.name.trim().is_empty() {
            errors.push(String::from("task.name must not be empty"));
            continue;
        }
        if !names.insert(task.name.clone()) {
            errors.push(format!("duplicate task name: {}", task.name));
        }
        if task.command.is_empty() || task.command[0].trim().is_empty() {
            errors.push(format!(
                "task {} must provide a non-empty command array",
                task.name
            ));
        }
    }

    for task in &config.tasks {
        for dep in &task.depends_on {
            if dep == &task.name {
                errors.push(format!("task {} depends on itself", task.name));
            } else if !names.contains(dep) {
                errors.push(format!(
                    "task {} references missing dependency {}",
                    task.name, dep
                ));
            }
        }
    }

    for (profile_name, profile) in &config.profiles {
        if profile.max_parallel == Some(0) {
            errors.push(format!("profile {} has max_parallel=0", profile_name));
        }
        for task_name in profile.tasks.keys() {
            if !names.contains(task_name) {
                errors.push(format!(
                    "profile {} references missing task {}",
                    profile_name, task_name
                ));
            }
        }
    }

    if errors.is_empty()
        && let Err(err) = topological_order(config, None)
    {
        errors.push(err);
    }

    errors
}