rototo 0.1.0-alpha.2

Control plane for runtime configuration in application code.
Documentation
use std::path::{Path, PathBuf};

use toml::Value;

use crate::error::{Result, RototoError};
use crate::model::{
    QualifierConfig, QualifierInspection, VariableConfig, VariableInspection, WorkspaceInspection,
};

const WORKSPACE_MANIFEST: &str = "rototo-workspace.toml";
const SUPPORTED_SCHEMA_VERSION: i64 = 1;

pub async fn inspect_workspace(workspace_root: &Path) -> Result<WorkspaceInspection> {
    let workspace_root = tokio::fs::canonicalize(workspace_root)
        .await
        .map_err(|err| RototoError::new(format!("workspace not found: {err}")))?;
    let manifest = read_toml(&workspace_root.join(WORKSPACE_MANIFEST)).await?;
    let environments = workspace_environments(&manifest)?;
    let qualifiers = discover_qualifiers(&workspace_root).await?;
    let variables = discover_variables(&workspace_root).await?;

    Ok(WorkspaceInspection {
        root: workspace_root,
        environments,
        qualifiers,
        variables,
    })
}

pub async fn read_toml(path: &Path) -> Result<Value> {
    let text = tokio::fs::read_to_string(path)
        .await
        .map_err(|err| RototoError::new(format!("failed to read {}: {err}", path.display())))?;
    toml::from_str::<Value>(&text)
        .map_err(|err| RototoError::new(format!("failed to parse {}: {err}", path.display())))
}

pub async fn read_variable_toml(
    workspace_root: &Path,
    variable: &VariableInspection,
) -> Result<Value> {
    let path = workspace_root.join(&variable.path);
    let mut toml = read_toml(&path).await?;
    merge_external_variable_values(workspace_root, variable, &mut toml).await?;
    Ok(toml)
}

pub fn qualifier_for_id<'a>(
    inspection: &'a WorkspaceInspection,
    id: &str,
) -> Result<&'a QualifierInspection> {
    inspection
        .qualifiers
        .iter()
        .find(|qualifier| qualifier.id == id)
        .ok_or_else(|| RototoError::new(format!("qualifier not found: qualifier://{id}")))
}

pub fn variable_for_id<'a>(
    inspection: &'a WorkspaceInspection,
    id: &str,
) -> Result<&'a VariableInspection> {
    inspection
        .variables
        .iter()
        .find(|variable| variable.id == id)
        .ok_or_else(|| RototoError::new(format!("variable not found: variable://{id}")))
}

pub async fn list_qualifiers(workspace_root: &Path) -> Result<Vec<QualifierInspection>> {
    Ok(inspect_workspace(workspace_root).await?.qualifiers)
}

pub async fn list_variables(workspace_root: &Path) -> Result<Vec<VariableInspection>> {
    Ok(inspect_workspace(workspace_root).await?.variables)
}

pub async fn read_qualifier(workspace_root: &Path, id: &str) -> Result<QualifierConfig> {
    let inspection = inspect_workspace(workspace_root).await?;
    let qualifier = qualifier_for_id(&inspection, id)?;
    qualifier_config(&inspection.root, qualifier).await
}

pub async fn read_variable(workspace_root: &Path, id: &str) -> Result<VariableConfig> {
    let inspection = inspect_workspace(workspace_root).await?;
    let variable = variable_for_id(&inspection, id)?;
    variable_config(&inspection.root, variable).await
}

pub async fn read_qualifiers(workspace_root: &Path) -> Result<Vec<QualifierConfig>> {
    let inspection = inspect_workspace(workspace_root).await?;
    let mut configs = Vec::new();
    for qualifier in &inspection.qualifiers {
        configs.push(qualifier_config(&inspection.root, qualifier).await?);
    }
    Ok(configs)
}

pub async fn read_variables(workspace_root: &Path) -> Result<Vec<VariableConfig>> {
    let inspection = inspect_workspace(workspace_root).await?;
    let mut configs = Vec::new();
    for variable in &inspection.variables {
        configs.push(variable_config(&inspection.root, variable).await?);
    }
    Ok(configs)
}

pub fn workspace_environments(manifest: &Value) -> Result<Vec<String>> {
    let schema_version = manifest
        .get("schema_version")
        .and_then(Value::as_integer)
        .ok_or_else(|| RototoError::new("workspace manifest must declare schema_version = 1"))?;
    if schema_version != SUPPORTED_SCHEMA_VERSION {
        return Err(RototoError::new(format!(
            "unsupported workspace schema_version: {schema_version}"
        )));
    }

    let values = manifest
        .get("environments")
        .and_then(|environments| environments.get("values"))
        .and_then(Value::as_array)
        .ok_or_else(|| RototoError::new("workspace manifest must declare [environments].values"))?;

    let mut environments = Vec::new();
    for value in values {
        let environment = value
            .as_str()
            .ok_or_else(|| RototoError::new("environment names must be strings"))?;
        if environment == "_" {
            return Err(RototoError::new(
                "_ is reserved as the catch-all environment",
            ));
        }
        if environments.iter().any(|existing| existing == environment) {
            return Err(RototoError::new(format!(
                "duplicate environment: {environment}"
            )));
        }
        environments.push(environment.to_owned());
    }

    if environments.is_empty() {
        return Err(RototoError::new(
            "workspace must declare at least one environment",
        ));
    }
    Ok(environments)
}

async fn qualifier_config(
    workspace_root: &Path,
    qualifier: &QualifierInspection,
) -> Result<QualifierConfig> {
    let value = serde_json::to_value(read_toml(&workspace_root.join(&qualifier.path)).await?)
        .map_err(|err| RototoError::new(err.to_string()))?;

    Ok(QualifierConfig {
        id: qualifier.id.clone(),
        uri: qualifier.uri.clone(),
        path: qualifier.path.clone(),
        value,
    })
}

async fn variable_config(
    workspace_root: &Path,
    variable: &VariableInspection,
) -> Result<VariableConfig> {
    let value = serde_json::to_value(read_variable_toml(workspace_root, variable).await?)
        .map_err(|err| RototoError::new(err.to_string()))?;

    Ok(VariableConfig {
        id: variable.id.clone(),
        uri: variable.uri.clone(),
        path: variable.path.clone(),
        value,
    })
}

async fn discover_qualifiers(workspace_root: &Path) -> Result<Vec<QualifierInspection>> {
    let mut qualifiers = Vec::new();
    for path in discover_named_toml_files(workspace_root, "qualifiers").await? {
        let id = id_from_path(&path)?;
        let relative_path = relative_path(workspace_root, &path)?;
        qualifiers.push(QualifierInspection {
            uri: format!("qualifier://{id}"),
            id,
            path: relative_path,
        });
    }
    qualifiers.sort_by(|left, right| left.uri.cmp(&right.uri));
    Ok(qualifiers)
}

async fn discover_variables(workspace_root: &Path) -> Result<Vec<VariableInspection>> {
    let mut variables = Vec::new();
    for path in discover_named_toml_files(workspace_root, "variables").await? {
        let id = id_from_path(&path)?;
        let relative_path = relative_path(workspace_root, &path)?;
        variables.push(VariableInspection {
            uri: format!("variable://{id}"),
            id,
            path: relative_path,
        });
    }
    variables.sort_by(|left, right| left.uri.cmp(&right.uri));
    Ok(variables)
}

async fn discover_named_toml_files(workspace_root: &Path, dir: &str) -> Result<Vec<PathBuf>> {
    let dir = workspace_root.join(dir);
    let mut paths = Vec::new();
    let Ok(mut entries) = tokio::fs::read_dir(&dir).await else {
        return Ok(paths);
    };
    while let Some(entry) = entries
        .next_entry()
        .await
        .map_err(|err| RototoError::new(format!("failed to read {}: {err}", dir.display())))?
    {
        let path = entry.path();
        if path.extension().and_then(|extension| extension.to_str()) == Some("toml")
            && tokio::fs::metadata(&path)
                .await
                .is_ok_and(|metadata| metadata.is_file())
        {
            paths.push(path);
        }
    }
    Ok(paths)
}

fn id_from_path(path: &Path) -> Result<String> {
    path.file_stem()
        .and_then(|stem| stem.to_str())
        .map(str::to_owned)
        .ok_or_else(|| RototoError::new(format!("path has no valid id: {path:?}")))
}

fn relative_path(workspace_root: &Path, path: &Path) -> Result<PathBuf> {
    path.strip_prefix(workspace_root)
        .map(Path::to_path_buf)
        .map_err(|err| RototoError::new(err.to_string()))
}

async fn merge_external_variable_values(
    workspace_root: &Path,
    variable: &VariableInspection,
    toml: &mut Value,
) -> Result<()> {
    let values_dir = external_values_dir(workspace_root, variable);
    if !tokio::fs::metadata(&values_dir)
        .await
        .is_ok_and(|metadata| metadata.is_dir())
    {
        return Ok(());
    }

    let mut external_values = toml::map::Map::new();
    let mut entries = tokio::fs::read_dir(&values_dir).await.map_err(|err| {
        RototoError::new(format!(
            "failed to read variable values directory {}: {err}",
            values_dir.display()
        ))
    })?;
    while let Some(entry) = entries
        .next_entry()
        .await
        .map_err(|err| RototoError::new(err.to_string()))?
    {
        let path = entry.path();
        if path.extension().and_then(|extension| extension.to_str()) != Some("toml")
            || !tokio::fs::metadata(&path)
                .await
                .is_ok_and(|metadata| metadata.is_file())
        {
            continue;
        }
        let id = id_from_path(&path)?;
        let value = value_from_external_value_toml(&path).await?;
        if external_values.insert(id.clone(), value).is_some() {
            return Err(RototoError::new(format!(
                "duplicate external variable value: {id}"
            )));
        }
    }

    if external_values.is_empty() {
        return Ok(());
    }

    let Some(root_table) = toml.as_table_mut() else {
        return Ok(());
    };
    let Some(variable_table) = root_table.get_mut("variable").and_then(Value::as_table_mut) else {
        return Ok(());
    };
    let values = variable_table
        .entry("values".to_owned())
        .or_insert_with(|| Value::Table(toml::map::Map::new()));
    let Some(values) = values.as_table_mut() else {
        return Err(RototoError::new(format!(
            "variable values must be a table: {}",
            variable.path.display()
        )));
    };

    for (id, value) in external_values {
        if values.insert(id.clone(), value).is_some() {
            return Err(RototoError::new(format!(
                "variable value is declared more than once: {id}"
            )));
        }
    }
    Ok(())
}

fn external_values_dir(workspace_root: &Path, variable: &VariableInspection) -> PathBuf {
    workspace_root
        .join(&variable.path)
        .parent()
        .unwrap_or(workspace_root)
        .join(format!("{}-values", variable.id))
}

async fn value_from_external_value_toml(path: &Path) -> Result<Value> {
    let value = read_toml(path).await?;
    let Some(table) = value.as_table() else {
        return Ok(value);
    };
    if table.len() == 1 && table.contains_key("value") {
        return Ok(table.get("value").expect("value key checked").clone());
    }
    Ok(value)
}