mechutil 0.8.11

Utility structures and functions for mechatronics applications.
Documentation
/*
 * Copyright (c) 2024 - 2025 Automated Design Corp. All Rights Reserved.
 */

//! Machine "configuration" overlays for module configs.
//!
//! A project can carry named `configurations` — sparse overlays applied on top
//! of a module's base `config` to produce the concrete config for one hardware
//! build (e.g. an AKD vs. Teknic drive, FCML on/off, a different lead-screw
//! scaling). Selection is a machine-global fact (see the server's
//! `[general] active_configuration`); the server resolves the overlay for every
//! module before spawn so modules receive already-correct config and never see
//! the `configurations` block.
//!
//! An overlay is a *sparse mirror* of the base config tree:
//! - Objects deep-merge (overlay wins).
//! - Arrays whose every element is an object with a string `name` merge
//!   element-by-element, keyed by `name`. Overlay elements with a name not in
//!   the base are appended.
//! - An overlay object carrying `"$replace": true` replaces the matched value
//!   wholesale instead of deep-merging (the sentinel is stripped from the
//!   result). This expresses a full-block swap, e.g. restating an entire slave
//!   for a drive-identity change with nothing inherited from the base.
//! - Any other mismatch (scalars, non-named arrays) replaces.

use serde_json::Value;
use std::fmt;

const NAME_KEY: &str = "name";
const REPLACE_KEY: &str = "$replace";
const CONFIGURATIONS_KEY: &str = "configurations";
const DEFAULT_CONFIGURATION_KEY: &str = "default_configuration";

/// Error resolving the active configuration for a module config.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigResolveError {
    /// The config declares `configurations` but neither an override name was
    /// supplied nor a `default_configuration` is set. We refuse to guess —
    /// running the wrong hardware build is the dangerous case.
    NoSelection,
    /// The selected name is not among the config's `configurations`.
    UnknownConfiguration { name: String, available: Vec<String> },
}

impl fmt::Display for ConfigResolveError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ConfigResolveError::NoSelection => write!(
                f,
                "configurations are defined but no active configuration was selected \
                 and no default_configuration is set"
            ),
            ConfigResolveError::UnknownConfiguration { name, available } => write!(
                f,
                "unknown configuration '{}' (available: {})",
                name,
                available.join(", ")
            ),
        }
    }
}

impl std::error::Error for ConfigResolveError {}

/// Deep-merge `overlay` into `base` in place (see module docs for the rules).
pub fn apply_configuration_overlay(base: &mut Value, overlay: &Value) {
    // `$replace` sentinel: overlay replaces `base` wholesale, sentinel stripped.
    if has_replace_sentinel(overlay) {
        *base = cloned_without_sentinel(overlay);
        return;
    }

    match (base, overlay) {
        (Value::Object(b), Value::Object(o)) => {
            for (k, ov) in o {
                match b.get_mut(k) {
                    Some(bv) => apply_configuration_overlay(bv, ov),
                    None => {
                        b.insert(k.clone(), cloned_without_sentinel(ov));
                    }
                }
            }
        }
        (Value::Array(b), Value::Array(o)) if is_named_array(b) && is_named_array(o) => {
            merge_named_array(b, o);
        }
        (b, o) => {
            *b = o.clone();
        }
    }
}

/// Resolve a module's config against the active configuration name.
///
/// - No (or empty) `configurations` block → the config is returned unchanged
///   (with any selection keys stripped) for full backward compatibility.
/// - Otherwise the name is `active` if given, else `default_configuration`.
///   A missing selection or an unknown name is a hard error.
///
/// The returned config never contains `configurations`/`default_configuration`.
pub fn resolve_active_config(
    config: &Value,
    active: Option<&str>,
) -> Result<Value, ConfigResolveError> {
    let configs = match config.get(CONFIGURATIONS_KEY) {
        Some(Value::Object(m)) if !m.is_empty() => m,
        // No configurations to apply — passthrough (drop selection keys if any).
        _ => return Ok(strip_config_keys(config)),
    };

    let default = config.get(DEFAULT_CONFIGURATION_KEY).and_then(Value::as_str);
    let name = active
        .or(default)
        .ok_or(ConfigResolveError::NoSelection)?;

    let overlay = configs.get(name).ok_or_else(|| {
        let mut available: Vec<String> = configs.keys().cloned().collect();
        available.sort();
        ConfigResolveError::UnknownConfiguration {
            name: name.to_string(),
            available,
        }
    })?;

    let mut resolved = strip_config_keys(config);
    apply_configuration_overlay(&mut resolved, overlay);
    Ok(strip_config_keys(&resolved))
}

fn has_replace_sentinel(v: &Value) -> bool {
    v.get(REPLACE_KEY) == Some(&Value::Bool(true))
}

fn cloned_without_sentinel(v: &Value) -> Value {
    let mut v = v.clone();
    if let Value::Object(m) = &mut v {
        m.remove(REPLACE_KEY);
    }
    v
}

/// True iff every element is an object carrying a string `name` (and non-empty).
fn is_named_array(arr: &[Value]) -> bool {
    !arr.is_empty()
        && arr
            .iter()
            .all(|e| e.get(NAME_KEY).and_then(Value::as_str).is_some())
}

fn merge_named_array(base: &mut Vec<Value>, overlay: &[Value]) {
    for ov in overlay {
        let name = ov.get(NAME_KEY).and_then(Value::as_str).unwrap();
        match base
            .iter_mut()
            .find(|e| e.get(NAME_KEY).and_then(Value::as_str) == Some(name))
        {
            Some(existing) => apply_configuration_overlay(existing, ov),
            None => base.push(cloned_without_sentinel(ov)),
        }
    }
}

fn strip_config_keys(config: &Value) -> Value {
    let mut c = config.clone();
    if let Value::Object(m) = &mut c {
        m.remove(CONFIGURATIONS_KEY);
        m.remove(DEFAULT_CONFIGURATION_KEY);
    }
    c
}

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

    #[test]
    fn deep_merge_objects() {
        let mut base = json!({ "axes": { "scaling": 1.0, "fcml": { "enabled": true, "kp": 2.0 } } });
        let overlay = json!({ "axes": { "fcml": { "enabled": false } } });
        apply_configuration_overlay(&mut base, &overlay);
        assert_eq!(
            base,
            json!({ "axes": { "scaling": 1.0, "fcml": { "enabled": false, "kp": 2.0 } } })
        );
    }

    #[test]
    fn merge_named_array_patches_matching_element() {
        let mut base = json!({
            "slaves": [
                { "name": "main_drive", "position": 0, "watchdog": 100 },
                { "name": "load_encoder", "position": 1, "enabled": true }
            ]
        });
        let overlay = json!({ "slaves": [ { "name": "load_encoder", "enabled": false } ] });
        apply_configuration_overlay(&mut base, &overlay);
        assert_eq!(
            base,
            json!({
                "slaves": [
                    { "name": "main_drive", "position": 0, "watchdog": 100 },
                    { "name": "load_encoder", "position": 1, "enabled": false }
                ]
            })
        );
    }

    #[test]
    fn replace_sentinel_swaps_whole_element() {
        let mut base = json!({
            "slaves": [
                { "name": "main_drive", "device_id": { "vendor": "teknic" }, "startup_sdo": [1, 2, 3] }
            ]
        });
        let overlay = json!({
            "slaves": [
                { "name": "main_drive", "$replace": true,
                  "device_id": { "vendor": "akd" }, "startup_sdo": [9] }
            ]
        });
        apply_configuration_overlay(&mut base, &overlay);
        // Nothing inherited from base: no leftover teknic fields, no $replace key.
        assert_eq!(
            base,
            json!({
                "slaves": [
                    { "name": "main_drive", "device_id": { "vendor": "akd" }, "startup_sdo": [9] }
                ]
            })
        );
    }

    #[test]
    fn named_array_appends_unknown_element() {
        let mut base = json!({ "slaves": [ { "name": "a", "x": 1 } ] });
        let overlay = json!({ "slaves": [ { "name": "b", "y": 2, "$replace": true } ] });
        apply_configuration_overlay(&mut base, &overlay);
        assert_eq!(
            base,
            json!({ "slaves": [ { "name": "a", "x": 1 }, { "name": "b", "y": 2 } ] })
        );
    }

    #[test]
    fn resolve_passthrough_when_no_configurations() {
        let config = json!({ "interface": "eth0", "slaves": [] });
        assert_eq!(
            resolve_active_config(&config, Some("anything")).unwrap(),
            config
        );
    }

    #[test]
    fn resolve_strips_selection_keys() {
        let config = json!({
            "interface": "eth0",
            "default_configuration": "a",
            "configurations": { "a": { "interface": "eth1" } }
        });
        let resolved = resolve_active_config(&config, None).unwrap();
        assert_eq!(resolved, json!({ "interface": "eth1" }));
    }

    #[test]
    fn resolve_override_beats_default() {
        let config = json!({
            "v": "base",
            "default_configuration": "a",
            "configurations": { "a": { "v": "A" }, "b": { "v": "B" } }
        });
        assert_eq!(
            resolve_active_config(&config, Some("b")).unwrap(),
            json!({ "v": "B" })
        );
    }

    #[test]
    fn resolve_unknown_name_errors() {
        let config = json!({ "configurations": { "a": {} } });
        let err = resolve_active_config(&config, Some("zzz")).unwrap_err();
        match err {
            ConfigResolveError::UnknownConfiguration { name, available } => {
                assert_eq!(name, "zzz");
                assert_eq!(available, vec!["a".to_string()]);
            }
            _ => panic!("expected UnknownConfiguration, got {err:?}"),
        }
    }

    #[test]
    fn resolve_no_selection_errors() {
        let config = json!({ "configurations": { "a": {} } });
        assert_eq!(
            resolve_active_config(&config, None).unwrap_err(),
            ConfigResolveError::NoSelection
        );
    }
}