data-protocol-validator 0.1.0

Rust validator for Data Protocol schemas - validates versioned bioinformatics analysis output against JSON Schema-based protocol definitions
Documentation
use serde_json::Value;

use crate::types::Suggestion;

/// Convert a f64 to a `serde_json::Value`, using an integer representation
/// when the value has no fractional part (matching how JavaScript / JSON would
/// represent it).
fn number_to_json_value(n: f64) -> Value {
    if n.fract() == 0.0 && n.abs() < (i64::MAX as f64) {
        serde_json::json!(n as i64)
    } else {
        serde_json::json!(n)
    }
}

/// Suggest a type conversion fix for a type mismatch error (E001).
pub fn suggest_type_fix(data: &Value, expected_type: &str) -> Option<Suggestion> {
    match expected_type {
        "number" | "integer" => {
            if let Some(s) = data.as_str() {
                let trimmed = s.trim();
                if !trimmed.is_empty() {
                    if let Ok(num) = trimmed.parse::<f64>() {
                        if expected_type == "integer" {
                            if num.fract() == 0.0 {
                                return Some(Suggestion {
                                    action: "convert".to_string(),
                                    description: format!("Convert \"{}\" to integer", s),
                                    suggested_value: Some(number_to_json_value(num)),
                                });
                            }
                            // float string -> integer requested but not an integer
                            return None;
                        }
                        return Some(Suggestion {
                            action: "convert".to_string(),
                            description: format!("Convert \"{}\" to number", s),
                            suggested_value: Some(number_to_json_value(num)),
                        });
                    }
                }
            }
            if let Some(b) = data.as_bool() {
                let num = if b { 1 } else { 0 };
                return Some(Suggestion {
                    action: "convert".to_string(),
                    description: format!("Convert {} to number", b),
                    suggested_value: Some(serde_json::json!(num)),
                });
            }
            None
        }
        "string" => {
            if let Some(n) = data.as_f64() {
                // Format like JSON.stringify / String(data) in JS
                let s = format_number_as_js(n);
                return Some(Suggestion {
                    action: "convert".to_string(),
                    description: format!("Convert {} to string", format_json_value(data)),
                    suggested_value: Some(Value::String(s)),
                });
            }
            if let Some(b) = data.as_bool() {
                return Some(Suggestion {
                    action: "convert".to_string(),
                    description: format!("Convert {} to string", b),
                    suggested_value: Some(Value::String(b.to_string())),
                });
            }
            None
        }
        "boolean" => {
            if let Some(s) = data.as_str() {
                if s == "true" || s == "false" {
                    let bval = s == "true";
                    return Some(Suggestion {
                        action: "convert".to_string(),
                        description: format!("Convert \"{}\" to boolean", s),
                        suggested_value: Some(serde_json::json!(bval)),
                    });
                }
            }
            None
        }
        _ => None,
    }
}

/// Suggest a clamp fix for an out-of-range number (E005).
pub fn suggest_number_fix(data: f64, schema: &Value) -> Option<Suggestion> {
    if let Some(min) = schema.get("minimum").and_then(|v| v.as_f64()) {
        if data < min {
            return Some(Suggestion {
                action: "clamp".to_string(),
                description: format!("Clamp value to minimum {}", format_number(min)),
                suggested_value: Some(number_to_json_value(min)),
            });
        }
    }
    if let Some(max) = schema.get("maximum").and_then(|v| v.as_f64()) {
        if data > max {
            return Some(Suggestion {
                action: "clamp".to_string(),
                description: format!("Clamp value to maximum {}", format_number(max)),
                suggested_value: Some(number_to_json_value(max)),
            });
        }
    }
    if let Some(exc_min) = schema.get("exclusiveMinimum").and_then(|v| v.as_f64()) {
        if data <= exc_min {
            return Some(Suggestion {
                action: "clamp".to_string(),
                description: format!("Value must be greater than {}", format_number(exc_min)),
                suggested_value: Some(number_to_json_value(exc_min)),
            });
        }
    }
    if let Some(exc_max) = schema.get("exclusiveMaximum").and_then(|v| v.as_f64()) {
        if data >= exc_max {
            return Some(Suggestion {
                action: "clamp".to_string(),
                description: format!("Value must be less than {}", format_number(exc_max)),
                suggested_value: Some(number_to_json_value(exc_max)),
            });
        }
    }
    None
}

/// Suggest a truncation fix for a string that is too long (E004 maxLength).
pub fn suggest_string_fix(data: &str, schema: &Value) -> Option<Suggestion> {
    if let Some(max_len) = schema.get("maxLength").and_then(|v| v.as_u64()) {
        let max_len = max_len as usize;
        if data.len() > max_len {
            let truncated: String = data.chars().take(max_len).collect();
            return Some(Suggestion {
                action: "truncate".to_string(),
                description: format!("Truncate string to {} characters", max_len),
                suggested_value: Some(Value::String(truncated)),
            });
        }
    }
    None
}

/// Suggest adding a missing required property (E002).
pub fn suggest_missing_required(property: &str) -> Suggestion {
    Suggestion {
        action: "add".to_string(),
        description: format!("Add required property \"{}\"", property),
        suggested_value: None,
    }
}

/// Suggest removing an additional property (E003).
pub fn suggest_remove_additional(property: &str) -> Suggestion {
    Suggestion {
        action: "remove".to_string(),
        description: format!("Remove additional property \"{}\"", property),
        suggested_value: None,
    }
}

/// Suggest truncating an array that has too many items (E006 maxItems).
pub fn suggest_array_fix(schema: &Value) -> Option<Suggestion> {
    if let Some(max_items) = schema.get("maxItems").and_then(|v| v.as_u64()) {
        return Some(Suggestion {
            action: "truncate".to_string(),
            description: format!("Truncate array to {} items", max_items),
            suggested_value: None,
        });
    }
    None
}

/// Format a number the way JavaScript would with `String(n)`.
fn format_number_as_js(n: f64) -> String {
    if n.fract() == 0.0 && n.abs() < 1e15 {
        format!("{}", n as i64)
    } else {
        format!("{}", n)
    }
}

/// Format a number for display in messages.
fn format_number(n: f64) -> String {
    if n.fract() == 0.0 && n.abs() < 1e15 {
        format!("{}", n as i64)
    } else {
        format!("{}", n)
    }
}

/// Format a JSON value for display in messages (like JSON.stringify).
fn format_json_value(v: &Value) -> String {
    match v {
        Value::Number(n) => {
            if let Some(f) = n.as_f64() {
                format_number(f)
            } else {
                n.to_string()
            }
        }
        _ => v.to_string(),
    }
}