casper-devnet 0.10.0

Launcher for local Casper Network development networks.
Documentation
use anyhow::{Result, anyhow};
use toml_edit::{DocumentMut, Item, Table, table};

const BUILTIN_OVERRIDE_PATHS: &[&[&str]] = &[
    &["protocol", "activation_point"],
    &["protocol", "version"],
    &["network", "name"],
    &["core", "validator_slots"],
];

pub(crate) struct AppliedChainspecOverrides {
    pub(crate) contents: String,
    pub(crate) overwritten_builtin_paths: Vec<String>,
}

#[derive(Debug)]
struct ChainspecOverride {
    raw_path: String,
    path: Vec<String>,
    value: Item,
}

pub(crate) fn apply(contents: &str, raw_overrides: &[String]) -> Result<AppliedChainspecOverrides> {
    if raw_overrides.is_empty() {
        return Ok(AppliedChainspecOverrides {
            contents: contents.to_string(),
            overwritten_builtin_paths: Vec::new(),
        });
    }

    let overrides = raw_overrides
        .iter()
        .map(|raw| parse_override(raw))
        .collect::<Result<Vec<_>>>()?;
    let mut document = contents
        .parse::<DocumentMut>()
        .map_err(|err| anyhow!("failed to parse chainspec TOML: {err}"))?;
    let mut overwritten_builtin_paths = Vec::new();

    for override_value in overrides {
        if is_builtin_override_path(&override_value.path)
            && !overwritten_builtin_paths.contains(&override_value.raw_path)
        {
            overwritten_builtin_paths.push(override_value.raw_path.clone());
        }
        apply_override(&mut document, override_value)?;
    }

    Ok(AppliedChainspecOverrides {
        contents: document.to_string(),
        overwritten_builtin_paths,
    })
}

fn parse_override(raw: &str) -> Result<ChainspecOverride> {
    let (raw_path, raw_value) = raw
        .split_once('=')
        .ok_or_else(|| anyhow!("chainspec override '{raw}' must use KEY=VALUE syntax"))?;
    let raw_path = raw_path.trim();
    let raw_value = raw_value.trim();
    if raw_value.is_empty() {
        return Err(anyhow!(
            "chainspec override '{raw}' has an empty TOML value"
        ));
    }

    let path = parse_path(raw_path)?;
    let value = parse_value(raw, raw_value)?;

    Ok(ChainspecOverride {
        raw_path: path.join("."),
        path,
        value,
    })
}

fn parse_path(raw_path: &str) -> Result<Vec<String>> {
    if raw_path.is_empty() {
        return Err(anyhow!("chainspec override path must not be empty"));
    }

    raw_path
        .split('.')
        .map(|segment| {
            if segment.is_empty() {
                return Err(anyhow!(
                    "chainspec override path '{raw_path}' contains an empty segment"
                ));
            }
            if !is_simple_key_segment(segment) {
                return Err(anyhow!(
                    "chainspec override path segment '{segment}' is not a simple TOML key"
                ));
            }
            Ok(segment.to_string())
        })
        .collect()
}

fn is_simple_key_segment(segment: &str) -> bool {
    segment
        .bytes()
        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
}

fn parse_value(raw: &str, raw_value: &str) -> Result<Item> {
    let synthetic = format!("value = {raw_value}");
    let document = synthetic
        .parse::<DocumentMut>()
        .map_err(|err| anyhow!("invalid TOML value in chainspec override '{raw}': {err}"))?;
    document
        .as_item()
        .get("value")
        .cloned()
        .ok_or_else(|| anyhow!("chainspec override '{raw}' did not produce a TOML value"))
}

fn is_builtin_override_path(path: &[String]) -> bool {
    BUILTIN_OVERRIDE_PATHS
        .iter()
        .any(|builtin| path.iter().map(String::as_str).eq(builtin.iter().copied()))
}

fn apply_override(document: &mut DocumentMut, override_value: ChainspecOverride) -> Result<()> {
    let path_display = override_value.raw_path.clone();
    apply_to_table(
        document.as_table_mut(),
        &override_value.path,
        override_value.value,
        &path_display,
    )
}

fn apply_to_table(
    table_value: &mut Table,
    path: &[String],
    value: Item,
    path_display: &str,
) -> Result<()> {
    let key = &path[0];
    if path.len() == 1 {
        table_value.insert(key, value);
        return Ok(());
    }

    if !table_value.contains_key(key) {
        table_value.insert(key, table());
    }

    let item = table_value
        .get_key_value_mut(key)
        .map(|(_, item)| item)
        .ok_or_else(|| anyhow!("failed to create chainspec override table '{key}'"))?;
    let item_type = item.type_name();
    let child = item.as_table_mut().ok_or_else(|| {
        anyhow!(
            "cannot apply chainspec override '{path_display}': '{key}' is a {item_type}, not a table"
        )
    })?;
    apply_to_table(child, &path[1..], value, path_display)
}

#[cfg(test)]
mod tests {
    use super::*;

    const BASE: &str = "\
[protocol]
activation_point = 1
version = '1.0.0'

[network]
name = 'casper-dev'

[core]
validator_slots = 4
";

    fn apply_one(raw: &str) -> DocumentMut {
        apply(BASE, &[raw.to_string()])
            .unwrap()
            .contents
            .parse::<DocumentMut>()
            .unwrap()
    }

    #[test]
    fn applies_string_value() {
        let document = apply_one("highway.era_duration='1hour'");

        assert_eq!(
            document["highway"]["era_duration"]
                .as_value()
                .and_then(toml_edit::Value::as_str),
            Some("1hour")
        );
    }

    #[test]
    fn applies_integer_value() {
        let document = apply_one("core.minimum_era_height=1");

        assert_eq!(
            document["core"]["minimum_era_height"]
                .as_value()
                .and_then(toml_edit::Value::as_integer),
            Some(1)
        );
    }

    #[test]
    fn applies_boolean_value() {
        let document = apply_one("core.allow_auction_bids=false");

        assert_eq!(
            document["core"]["allow_auction_bids"]
                .as_value()
                .and_then(toml_edit::Value::as_bool),
            Some(false)
        );
    }

    #[test]
    fn applies_array_with_spaces() {
        let document = apply_one("core.test_values=[1, 10]");
        let array = document["core"]["test_values"]
            .as_value()
            .and_then(toml_edit::Value::as_array)
            .unwrap();

        assert_eq!(array.get(0).and_then(toml_edit::Value::as_integer), Some(1));
        assert_eq!(
            array.get(1).and_then(toml_edit::Value::as_integer),
            Some(10)
        );
    }

    #[test]
    fn applies_array_without_spaces() {
        let document = apply_one("core.test_values=[1,10]");
        let array = document["core"]["test_values"]
            .as_value()
            .and_then(toml_edit::Value::as_array)
            .unwrap();

        assert_eq!(array.get(0).and_then(toml_edit::Value::as_integer), Some(1));
        assert_eq!(
            array.get(1).and_then(toml_edit::Value::as_integer),
            Some(10)
        );
    }

    #[test]
    fn applies_inline_table() {
        let document = apply_one("core.rewards_handling={ type = 'standard' }");
        let table = document["core"]["rewards_handling"]
            .as_value()
            .and_then(toml_edit::Value::as_inline_table)
            .unwrap();

        assert_eq!(
            table.get("type").and_then(toml_edit::Value::as_str),
            Some("standard")
        );
    }

    #[test]
    fn rejects_missing_equals() {
        let err = parse_override("core.minimum_era_height").unwrap_err();

        assert!(err.to_string().contains("KEY=VALUE"));
    }

    #[test]
    fn rejects_empty_path_segments() {
        let err = parse_override("core..minimum_era_height=1").unwrap_err();

        assert!(err.to_string().contains("empty segment"));
    }

    #[test]
    fn rejects_invalid_toml_values() {
        let err = parse_override("core.minimum_era_height=[1,").unwrap_err();

        assert!(err.to_string().contains("invalid TOML value"));
    }

    #[test]
    fn reports_overwritten_builtin_paths() {
        let applied = apply(BASE, &["core.validator_slots=99".to_string()]).unwrap();

        assert_eq!(
            applied.overwritten_builtin_paths,
            vec!["core.validator_slots"]
        );
    }
}