o7 0.1.1

O7 workflow DSL runner
Documentation
use crate::harness::types::{CheckOutput, MatchOutput};

/// Parse and validate check result JSON from a raw stdout string.
/// Expects `{ "result": boolean, "reason"?: string }`.
/// Rejects additional properties beyond `result` and `reason`.
pub fn parse_check_result(stdout: &str) -> Result<CheckOutput, String> {
    let parsed: serde_json::Value = serde_json::from_str(stdout).map_err(|_| {
        format!(
            "Failed to parse check output as JSON: {}",
            &stdout[..stdout.len().min(200)]
        )
    })?;

    let obj = parsed
        .as_object()
        .ok_or("Check output must be a JSON object")?;

    let result = obj.get("result").and_then(|v| v.as_bool()).ok_or_else(|| {
        let actual_type = obj
            .get("result")
            .map(|v| format!("{}", v))
            .unwrap_or_else(|| "missing".to_string());
        format!(
            "Check output \"result\" must be a boolean, got {}",
            actual_type
        )
    })?;

    let reason = if let Some(r) = obj.get("reason") {
        Some(
            r.as_str()
                .ok_or_else(|| {
                    format!(
                        "Check output \"reason\" must be a string if present, got {}",
                        r
                    )
                })?
                .to_string(),
        )
    } else {
        None
    };

    // Reject additional properties
    let allowed_keys: std::collections::HashSet<&str> = ["result", "reason"].into_iter().collect();
    for key in obj.keys() {
        if !allowed_keys.contains(key.as_str()) {
            return Err(format!("Check output has unexpected property \"{}\"", key));
        }
    }

    Ok(CheckOutput { result, reason })
}

/// Parse and validate match result JSON from a raw stdout string.
/// Expects `{ "variant": string, "reason"?: string }`.
/// Rejects additional properties beyond `variant` and `reason`.
pub fn parse_match_result(stdout: &str) -> Result<MatchOutput, String> {
    let parsed: serde_json::Value = serde_json::from_str(stdout).map_err(|_| {
        format!(
            "Failed to parse match output as JSON: {}",
            &stdout[..stdout.len().min(200)]
        )
    })?;

    let obj = parsed
        .as_object()
        .ok_or("Match output must be a JSON object")?;

    let variant = obj.get("variant").and_then(|v| v.as_str()).ok_or_else(|| {
        let actual_type = obj
            .get("variant")
            .map(|v| format!("{}", v))
            .unwrap_or_else(|| "missing".to_string());
        format!(
            "Match output \"variant\" must be a non-empty string, got {}",
            actual_type
        )
    })?;

    if variant.is_empty() {
        return Err("Match output \"variant\" must be a non-empty string, got \"\"".to_string());
    }

    let reason = if let Some(r) = obj.get("reason") {
        Some(
            r.as_str()
                .ok_or_else(|| {
                    format!(
                        "Match output \"reason\" must be a string if present, got {}",
                        r
                    )
                })?
                .to_string(),
        )
    } else {
        None
    };

    // Reject additional properties
    let allowed_keys: std::collections::HashSet<&str> = ["variant", "reason"].into_iter().collect();
    for key in obj.keys() {
        if !allowed_keys.contains(key.as_str()) {
            return Err(format!("Match output has unexpected property \"{}\"", key));
        }
    }

    Ok(MatchOutput {
        variant: variant.to_string(),
        reason,
    })
}

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

    #[test]
    fn test_parse_valid_true() {
        let result = parse_check_result(r#"{"result": true, "reason": "all good"}"#).unwrap();
        assert!(result.result);
        assert_eq!(result.reason.as_deref(), Some("all good"));
    }

    #[test]
    fn test_parse_valid_false_no_reason() {
        let result = parse_check_result(r#"{"result": false}"#).unwrap();
        assert!(!result.result);
        assert!(result.reason.is_none());
    }

    #[test]
    fn test_parse_valid_true_no_reason() {
        let result = parse_check_result(r#"{"result": true}"#).unwrap();
        assert!(result.result);
        assert!(result.reason.is_none());
    }

    #[test]
    fn test_parse_invalid_json() {
        let result = parse_check_result("not json");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Failed to parse"));
    }

    #[test]
    fn test_parse_missing_result() {
        let result = parse_check_result(r#"{"reason": "oops"}"#);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("must be a boolean"));
    }

    #[test]
    fn test_parse_extra_property_rejected() {
        let result = parse_check_result(r#"{"result": true, "extra": "bad"}"#);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("unexpected property"));
    }

    #[test]
    fn test_parse_non_boolean_result() {
        let result = parse_check_result(r#"{"result": "yes"}"#);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("must be a boolean"));
    }

    #[test]
    fn test_parse_non_string_reason() {
        let result = parse_check_result(r#"{"result": true, "reason": 42}"#);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("must be a string"));
    }

    #[test]
    fn test_parse_array_rejected() {
        let result = parse_check_result(r#"[true]"#);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("must be a JSON object"));
    }

    #[test]
    fn test_parse_null_rejected() {
        let result = parse_check_result("null");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("must be a JSON object"));
    }

    #[test]
    fn test_parse_number_rejected() {
        let result = parse_check_result("42");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("must be a JSON object"));
    }

    #[test]
    fn test_parse_check_result_from_exec_stdout() {
        // Verifies the typical use case: parsing stdout from an ExecResult
        let stdout = r#"{"result": true, "reason": "pass"}"#;
        let output = parse_check_result(stdout).unwrap();
        assert!(output.result);
        assert_eq!(output.reason.as_deref(), Some("pass"));
    }

    // --- parse_match_result tests ---

    #[test]
    fn test_parse_match_valid_with_reason() {
        let result =
            parse_match_result(r#"{"variant": "small", "reason": "under 50 lines"}"#).unwrap();
        assert_eq!(result.variant, "small");
        assert_eq!(result.reason.as_deref(), Some("under 50 lines"));
    }

    #[test]
    fn test_parse_match_valid_no_reason() {
        let result = parse_match_result(r#"{"variant": "large"}"#).unwrap();
        assert_eq!(result.variant, "large");
        assert!(result.reason.is_none());
    }

    #[test]
    fn test_parse_match_empty_variant_rejected() {
        let result = parse_match_result(r#"{"variant": ""}"#);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("non-empty string"));
    }

    #[test]
    fn test_parse_match_missing_variant() {
        let result = parse_match_result(r#"{"reason": "oops"}"#);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("must be a non-empty string"));
    }

    #[test]
    fn test_parse_match_extra_property_rejected() {
        let result = parse_match_result(r#"{"variant": "small", "extra": "bad"}"#);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("unexpected property"));
    }

    #[test]
    fn test_parse_match_non_string_variant() {
        let result = parse_match_result(r#"{"variant": 42}"#);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("must be a non-empty string"));
    }

    #[test]
    fn test_parse_match_non_string_reason() {
        let result = parse_match_result(r#"{"variant": "small", "reason": 42}"#);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("must be a string"));
    }

    #[test]
    fn test_parse_match_invalid_json() {
        let result = parse_match_result("not json");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Failed to parse"));
    }

    #[test]
    fn test_parse_match_array_rejected() {
        let result = parse_match_result(r#"["small"]"#);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("must be a JSON object"));
    }

    #[test]
    fn test_parse_match_null_rejected() {
        let result = parse_match_result("null");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("must be a JSON object"));
    }
}