Skip to main content

ff_script/
result.rs

1//! Parse Valkey FCALL return values into structured results.
2//!
3//! Lua return convention (RFC-010 ยง4.9):
4//!   Success: {1, "OK", ...values}       or {1, "ALREADY_SATISFIED", ...}
5//!   Failure: {0, "ERROR_NAME", ...context}
6//!
7//! The FCALL result comes back as a ferriskey `Value::Array`.
8
9use crate::error::ScriptError;
10use ferriskey::Value;
11
12/// Parsed FCALL result from a FlowFabric Lua function.
13#[derive(Debug)]
14pub struct FcallResult {
15    /// 1 = success, 0 = failure.
16    pub success: bool,
17    /// Status string: "OK", "ALREADY_SATISFIED", "DUPLICATE", or error code.
18    pub status: String,
19    /// Remaining fields after status code and status string.
20    pub fields: Vec<Value>,
21}
22
23impl FcallResult {
24    /// Parse a raw `Value` returned by `FCALL` into a structured result.
25    pub fn parse(raw: &Value) -> Result<Self, ScriptError> {
26        let items = match raw {
27            Value::Array(arr) => arr,
28            // ff_version returns a bare string, not an array.
29            // Individual callers handle that; this parser is for the
30            // standard {status_code, status_string, ...} convention.
31            _ => {
32                return Err(ScriptError::Parse {
33                    fcall: "FcallResult::parse".into(),
34                    execution_id: None,
35                    message: format!("expected Array, got {:?}", value_type_name(raw)),
36                });
37            }
38        };
39
40        if items.is_empty() {
41            return Err(ScriptError::Parse {
42                fcall: "FcallResult::parse".into(),
43                execution_id: None,
44                message: "empty FCALL result array".into(),
45            });
46        }
47
48        // Element [0]: status code (Int 1 or 0)
49        let status_code = match items.first() {
50            Some(Ok(Value::Int(n))) => *n,
51            other => {
52                return Err(ScriptError::Parse {
53                    fcall: "FcallResult::parse".into(),
54                    execution_id: None,
55                    message: format!("expected Int at index 0, got {:?}", other),
56                });
57            }
58        };
59
60        // Element [1]: status string
61        let status = if items.len() > 1 {
62            value_to_string(items[1].as_ref().ok())
63        } else {
64            String::new()
65        };
66
67        // Remaining elements
68        let fields: Vec<Value> = items
69            .iter()
70            .skip(2)
71            .filter_map(|r| r.as_ref().ok().cloned())
72            .collect();
73
74        Ok(FcallResult {
75            success: status_code == 1,
76            status,
77            fields,
78        })
79    }
80
81    /// If this is a failure result, convert the status string to a ScriptError.
82    /// Returns Ok(self) if success.
83    ///
84    /// For variants carrying a detail payload (e.g. `CapabilityMismatch`,
85    /// `InvalidCapabilities`, `InvalidInput`) the first Lua-return field
86    /// is folded into the variant so callers can log the specifics of WHY
87    /// the call failed (which caps were missing, which bound was violated)
88    /// instead of an empty String placeholder.
89    pub fn into_success(self) -> Result<Self, ScriptError> {
90        if self.success {
91            Ok(self)
92        } else {
93            let detail = value_to_string(self.fields.first());
94            let err = ScriptError::from_code_with_detail(&self.status, &detail)
95                .unwrap_or_else(|| ScriptError::Parse {
96                    fcall: "FcallResult::into_success".into(),
97                    execution_id: None,
98                    message: format!("unknown error code: {}", self.status),
99                });
100            Err(err)
101        }
102    }
103
104    /// Get a field as a string, or empty string if missing.
105    pub fn field_str(&self, index: usize) -> String {
106        self.fields
107            .get(index)
108            .map(|v| value_to_string(Some(v)))
109            .unwrap_or_default()
110    }
111}
112
113/// Trait for converting a raw FCALL `Value` into a typed result.
114///
115/// Each contract Result type (e.g. `CreateExecutionResult`, `CompleteExecutionResult`)
116/// implements this trait to parse the Lua return into the appropriate Rust enum variant.
117pub trait FromFcallResult: Sized {
118    fn from_fcall_result(raw: &Value) -> Result<Self, ScriptError>;
119}
120
121/// Extract a string from a Value.
122fn value_to_string(v: Option<&Value>) -> String {
123    match v {
124        Some(Value::BulkString(b)) => String::from_utf8_lossy(b).into_owned(),
125        Some(Value::SimpleString(s)) => s.clone(),
126        Some(Value::Int(n)) => n.to_string(),
127        Some(Value::Okay) => "OK".into(),
128        _ => String::new(),
129    }
130}
131
132/// Type name for error messages.
133fn value_type_name(v: &Value) -> &'static str {
134    match v {
135        Value::Nil => "Nil",
136        Value::Int(_) => "Int",
137        Value::BulkString(_) => "BulkString",
138        Value::Array(_) => "Array",
139        Value::SimpleString(_) => "SimpleString",
140        Value::Okay => "Okay",
141        Value::Map(_) => "Map",
142        Value::Set(_) => "Set",
143        Value::Double(_) => "Double",
144        Value::Boolean(_) => "Boolean",
145        Value::BigNumber(_) => "BigNumber",
146        Value::VerbatimString { .. } => "VerbatimString",
147        Value::Attribute { .. } => "Attribute",
148        Value::Push { .. } => "Push",
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn ok_value(fields: Vec<Value>) -> Value {
157        let mut arr: Vec<Result<Value, ferriskey::Error>> = vec![
158            Ok(Value::Int(1)),
159            Ok(Value::BulkString("OK".into())),
160        ];
161        for f in fields {
162            arr.push(Ok(f));
163        }
164        Value::Array(arr)
165    }
166
167    fn err_value(code: &str) -> Value {
168        Value::Array(vec![
169            Ok(Value::Int(0)),
170            Ok(Value::BulkString(Vec::from(code.as_bytes()).into())),
171        ])
172    }
173
174    #[test]
175    fn parse_success() {
176        let raw = ok_value(vec![Value::BulkString("hello".into())]);
177        let result = FcallResult::parse(&raw).unwrap();
178        assert!(result.success);
179        assert_eq!(result.status, "OK");
180        assert_eq!(result.fields.len(), 1);
181        assert_eq!(result.field_str(0), "hello");
182    }
183
184    #[test]
185    fn parse_error() {
186        let raw = err_value("stale_lease");
187        let result = FcallResult::parse(&raw).unwrap();
188        assert!(!result.success);
189        assert_eq!(result.status, "stale_lease");
190    }
191
192    #[test]
193    fn into_success_ok() {
194        let raw = ok_value(vec![]);
195        let result = FcallResult::parse(&raw).unwrap().into_success();
196        assert!(result.is_ok());
197    }
198
199    #[test]
200    fn into_success_err() {
201        let raw = err_value("lease_expired");
202        let result = FcallResult::parse(&raw).unwrap().into_success();
203        assert!(result.is_err());
204        assert!(matches!(result.unwrap_err(), ScriptError::LeaseExpired));
205    }
206
207    #[test]
208    fn parse_non_array_fails() {
209        let raw = Value::SimpleString("hello".into());
210        let result = FcallResult::parse(&raw);
211        assert!(result.is_err());
212    }
213}