shine-cli 1.2.0

Cross-platform CLI for managed shell commands, app configs, and machine setup
Documentation
use anyhow::{Context, Result, bail};
use std::collections::BTreeSet;
use std::path::Path;

use crate::config::Config;

use super::{LoadedSysPreset, SysDriverKind, SysItem, SysItemMode, SysManifest};

pub(super) async fn load_sys_preset(config: &Config, os_id: &str) -> Result<LoadedSysPreset> {
    if os_id.contains('/') || os_id.contains('\\') || os_id.contains("..") {
        bail!("invalid os id: {os_id:?}");
    }
    let prefix = format!("sys/{os_id}");
    if !config.is_external_presets {
        crate::presets::extract_prefix(&prefix, config.presets_dir(), true).await?;
    }

    let root = Path::new("sys").join(os_id);
    let script_path = config.preset_path(root.join(sys_init_script_name(os_id)));
    if !script_path.exists() {
        bail!(
            "No init script found for '{}'. Expected: {}",
            os_id,
            script_path.display()
        );
    }

    let manifest_path = config.preset_path(root.join("shine.toml"));
    let content = tokio::fs::read_to_string(&manifest_path)
        .await
        .with_context(|| format!("reading {}", manifest_path.display()))?;
    let manifest = parse_and_validate_manifest(&content)
        .with_context(|| format!("parsing {}", manifest_path.display()))?;

    Ok(LoadedSysPreset {
        manifest,
        script_path,
    })
}

pub(super) fn sys_init_script_name(os_id: &str) -> &'static str {
    if os_id == "windows" {
        "init.ps1"
    } else {
        "init.sh"
    }
}

pub(super) fn parse_and_validate_manifest(content: &str) -> Result<SysManifest> {
    let manifest: SysManifest = toml::from_str(content)?;
    validate_manifest(&manifest)?;
    Ok(manifest)
}

fn validate_manifest(manifest: &SysManifest) -> Result<()> {
    let mut ids = BTreeSet::new();
    for item in &manifest.items {
        validate_item_id(&item.id)?;
        if item.label.trim().is_empty() {
            bail!("sys bootstrap item `{}` must have a label", item.id);
        }
        if !ids.insert(item.id.clone()) {
            bail!("duplicate sys bootstrap item id `{}`", item.id);
        }
        let mut env_keys = BTreeSet::new();
        for key in &item.required_env {
            let mut chars = key.chars();
            let valid = chars
                .next()
                .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
                && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
            if !valid {
                bail!(
                    "sys item `{}` has invalid required_env key `{key}`",
                    item.id
                );
            }
            if !env_keys.insert(key) {
                bail!("sys item `{}` repeats required_env key `{key}`", item.id);
            }
        }
        validate_driver_config(item)?;
    }

    if let Some(default_profile) = &manifest.default_profile
        && !manifest.profiles.contains_key(default_profile)
    {
        bail!("default profile `{default_profile}` is not defined");
    }

    for (profile_name, profile) in &manifest.profiles {
        for item_id in &profile.items {
            if !ids.contains(item_id) {
                bail!("profile `{profile_name}` references unknown item `{item_id}`");
            }
            if manifest
                .items
                .iter()
                .find(|item| item.id == *item_id)
                .is_some_and(|item| item.mode == SysItemMode::Managed)
            {
                bail!(
                    "profile `{profile_name}` references managed item `{item_id}`; enable it with `shine sys apply {item_id}`"
                );
            }
        }
    }

    Ok(())
}

fn validate_driver_config(item: &SysItem) -> Result<()> {
    if item.mode == SysItemMode::Init && item.driver != SysDriverKind::Script {
        bail!(
            "sys bootstrap item `{}` cannot use managed driver `{:?}`",
            item.id,
            item.driver
        );
    }
    let allowed: &[&str] = match item.driver {
        SysDriverKind::Script => &[],
        SysDriverKind::SplitDns => &["domain_env", "servers_env"],
        SysDriverKind::ManagedFile => &["source", "target", "transforms", "restart_hint"],
    };
    for key in item.config.keys() {
        if !allowed.contains(&key.as_str()) {
            bail!(
                "sys item `{}` has unknown {:?} driver config key `{key}`",
                item.id,
                item.driver
            );
        }
    }
    let require_string = |key: &str| -> Result<&str> {
        item.config
            .get(key)
            .and_then(toml::Value::as_str)
            .filter(|value| !value.trim().is_empty())
            .with_context(|| format!("sys item `{}` requires config `{key}`", item.id))
    };
    match item.driver {
        SysDriverKind::Script => {
            if !item.config.is_empty() {
                bail!(
                    "script sys item `{}` does not accept driver config",
                    item.id
                );
            }
        }
        SysDriverKind::SplitDns => {
            for key in ["domain_env", "servers_env"] {
                let env_key = require_string(key)?;
                if !item.required_env.iter().any(|required| required == env_key) {
                    bail!(
                        "sys item `{}` config `{key}` references `{env_key}` but required_env does not include it",
                        item.id
                    );
                }
            }
        }
        SysDriverKind::ManagedFile => {
            require_string("source")?;
            require_string("target")?;
            if let Some(transforms) = item.config.get("transforms") {
                let transforms = transforms.as_array().with_context(|| {
                    format!(
                        "sys item `{}` config `transforms` must be an array",
                        item.id
                    )
                })?;
                if transforms.iter().any(|value| value.as_str().is_none()) {
                    bail!(
                        "sys item `{}` config `transforms` must contain strings",
                        item.id
                    );
                }
            }
        }
    }
    Ok(())
}

fn validate_item_id(item_id: &str) -> Result<()> {
    if item_id.trim().is_empty() {
        bail!("sys bootstrap item ids must not be empty");
    }
    if !item_id
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
    {
        bail!(
            "sys bootstrap item id `{item_id}` contains invalid characters (allowed: a-z A-Z 0-9 - _)"
        );
    }
    Ok(())
}