Skip to main content

agent_first_data/document/
coerce.rs

1//! Coerce CLI string arguments to Value.
2
3use crate::document::{DocumentError, DocumentResult, Value};
4
5/// Honor an explicit type prefix, if present:
6/// - `s:value` → String (no coercion)
7/// - `b:value` → Bool (true/yes/on/1)
8/// - `i:value` → Integer
9/// - `j:value` → JSON parse
10///
11/// Returns `None` when `s` carries no recognized prefix, so the caller can apply
12/// its own (typed or shape-based) coercion.
13fn explicit_prefix(s: &str) -> DocumentResult<Option<Value>> {
14    if let Some(rest) = s.strip_prefix("s:") {
15        return Ok(Some(Value::String(rest.to_string())));
16    }
17    if let Some(rest) = s.strip_prefix("b:") {
18        let value = parse_bool(rest).ok_or_else(|| DocumentError::ParseError {
19            format: "boolean".to_string(),
20            detail: format!(
21                "invalid b: value `{rest}`; expected true/false, yes/no, on/off, or 1/0"
22            ),
23        })?;
24        return Ok(Some(Value::Bool(value)));
25    }
26    if let Some(rest) = s.strip_prefix("i:") {
27        return rest
28            .parse::<i64>()
29            .map(Value::Integer)
30            .map(Some)
31            .map_err(|_| DocumentError::ParseError {
32                format: "integer".to_string(),
33                detail: format!("invalid i: value `{rest}`"),
34            });
35    }
36    if let Some(rest) = s.strip_prefix("j:") {
37        let value = serde_json::from_str::<serde_json::Value>(rest).map_err(|error| {
38            DocumentError::ParseError {
39                format: "JSON".to_string(),
40                detail: error.to_string(),
41            }
42        })?;
43        return Ok(Some(Value::from(value)));
44    }
45    Ok(None)
46}
47
48/// A bare scalar with no type context: parse an explicit `[...]`/`{...}` JSON
49/// literal, otherwise keep it as a String. Deliberately does NOT guess numbers
50/// or bools from shape — that is the job of type-directed coercion.
51fn json_or_string(s: &str) -> Value {
52    if (s.starts_with('[') || s.starts_with('{'))
53        && s.trim_end().ends_with([']', '}'])
54        && let Ok(v) = serde_json::from_str::<serde_json::Value>(s)
55    {
56        return Value::from(v);
57    }
58    Value::String(s.to_string())
59}
60
61/// Coerce a single CLI string toward the type already present at the target
62/// (`existing`) — never by guessing the string's shape. An explicit prefix
63/// always wins, and `"null"` always clears. When the target is a typed scalar
64/// the string is parsed into that type (falling back to String so the consumer's
65/// deserialize step produces a precise error); when the target is absent/null or
66/// a container, only an explicit JSON literal is structured, otherwise it stays a
67/// String.
68pub fn coerce_scalar_typed(s: &str, existing: Option<&Value>) -> DocumentResult<Value> {
69    if let Some(v) = explicit_prefix(s)? {
70        return Ok(v);
71    }
72    if s.eq_ignore_ascii_case("null") {
73        return Ok(Value::Null);
74    }
75    match existing {
76        Some(Value::Bool(_)) => Ok(parse_bool(s)
77            .map(Value::Bool)
78            .unwrap_or_else(|| Value::String(s.to_string()))),
79        Some(Value::Integer(_)) => Ok(s
80            .parse::<i64>()
81            .map(Value::Integer)
82            .unwrap_or_else(|_| Value::String(s.to_string()))),
83        Some(Value::Float(_)) => Ok(s
84            .parse::<f64>()
85            .map(Value::Float)
86            .unwrap_or_else(|_| Value::String(s.to_string()))),
87        Some(Value::Unsigned(_)) => Ok(s
88            .parse::<u64>()
89            .map(Value::Unsigned)
90            .unwrap_or_else(|_| Value::String(s.to_string()))),
91        Some(Value::String(_)) => Ok(Value::String(s.to_string())),
92        Some(_) => Ok(json_or_string(s)),
93        None => Ok(coerce_scalar(s)),
94    }
95}
96
97/// Type-directed coercion of a CLI value slice toward the type at `existing`.
98///
99/// - Empty slice → Error
100/// - Single value → [`coerce_scalar_typed`]
101/// - Multiple values → Array, each element coerced toward the existing array's
102///   element type (String when unknown)
103pub fn coerce_values_typed(values: &[String], existing: Option<&Value>) -> DocumentResult<Value> {
104    if values.is_empty() {
105        return Err(DocumentError::EmptyValues);
106    }
107    if values.len() == 1 {
108        return coerce_scalar_typed(&values[0], existing);
109    }
110    let elem = existing.and_then(Value::as_array).and_then(|a| a.first());
111    Ok(Value::Array(
112        values
113            .iter()
114            .map(|v| coerce_scalar_typed(v, elem))
115            .collect::<DocumentResult<Vec<_>>>()?,
116    ))
117}
118
119/// Shape-based coercion with no type context: an explicit prefix wins, otherwise
120/// `null`/`true`/`false`/integer/float/JSON are recognized by shape, falling back
121/// to String. Used where no target type is available (e.g. keyed-list fields
122/// without a default template).
123pub fn coerce_scalar(s: &str) -> Value {
124    if let Ok(Some(v)) = explicit_prefix(s) {
125        return v;
126    }
127    match s.to_lowercase().as_str() {
128        "null" => return Value::Null,
129        "true" => return Value::Bool(true),
130        "false" => return Value::Bool(false),
131        _ => {}
132    }
133    if let Ok(i) = s.parse::<i64>() {
134        return Value::Integer(i);
135    }
136    if let Ok(u) = s.parse::<u64>() {
137        return Value::Unsigned(u);
138    }
139    if let Ok(f) = s.parse::<f64>() {
140        return Value::Float(f);
141    }
142    json_or_string(s)
143}
144
145fn parse_bool(value: &str) -> Option<bool> {
146    match value.to_ascii_lowercase().as_str() {
147        "true" | "yes" | "on" | "1" => Some(true),
148        "false" | "no" | "off" | "0" => Some(false),
149        _ => None,
150    }
151}
152
153/// Coerce a slice of CLI values to Value via shape-based [`coerce_scalar`].
154///
155/// - Empty slice → Error
156/// - Single value → coerce_scalar
157/// - Multiple values → Array of coerced values
158pub fn coerce_values(values: &[String]) -> DocumentResult<Value> {
159    if values.is_empty() {
160        return Err(DocumentError::EmptyValues);
161    }
162
163    if values.len() == 1 {
164        return Ok(coerce_scalar(&values[0]));
165    }
166
167    Ok(Value::Array(
168        values.iter().map(|v| coerce_scalar(v)).collect(),
169    ))
170}
171
172#[cfg(test)]
173mod tests {
174    #![allow(clippy::unwrap_used, clippy::panic)]
175    use super::*;
176
177    #[test]
178    fn test_coerce_null() {
179        assert_eq!(coerce_scalar("null"), Value::Null);
180        assert_eq!(coerce_scalar("NULL"), Value::Null);
181    }
182
183    #[test]
184    fn test_coerce_bool() {
185        assert_eq!(coerce_scalar("true"), Value::Bool(true));
186        assert_eq!(coerce_scalar("false"), Value::Bool(false));
187        assert_eq!(coerce_scalar("TRUE"), Value::Bool(true));
188    }
189
190    #[test]
191    fn test_coerce_integer() {
192        assert_eq!(coerce_scalar("42"), Value::Integer(42));
193        assert_eq!(coerce_scalar("-100"), Value::Integer(-100));
194    }
195
196    #[test]
197    #[allow(clippy::approx_constant)]
198    fn test_coerce_float() {
199        match coerce_scalar("3.14") {
200            Value::Float(f) => assert!((f - 3.14).abs() < 0.001),
201            _ => panic!("expected float"),
202        }
203    }
204
205    #[test]
206    fn test_coerce_string() {
207        assert_eq!(coerce_scalar("hello"), Value::String("hello".to_string()));
208    }
209
210    #[test]
211    fn test_coerce_explicit_prefix_s() {
212        assert_eq!(coerce_scalar("s:true"), Value::String("true".to_string()));
213        assert_eq!(coerce_scalar("s:42"), Value::String("42".to_string()));
214    }
215
216    #[test]
217    fn test_coerce_explicit_prefix_b() {
218        assert_eq!(coerce_scalar("b:yes"), Value::Bool(true));
219        assert_eq!(coerce_scalar("b:no"), Value::Bool(false));
220    }
221
222    #[test]
223    fn test_coerce_explicit_prefix_i() {
224        assert_eq!(coerce_scalar("i:999"), Value::Integer(999));
225    }
226
227    #[test]
228    fn test_coerce_values_single() {
229        let result = coerce_values(&["42".to_string()]).unwrap();
230        assert_eq!(result, Value::Integer(42));
231    }
232
233    #[test]
234    fn test_coerce_values_multiple() {
235        let result =
236            coerce_values(&["dev".to_string(), "staging".to_string(), "prod".to_string()]).unwrap();
237        match result {
238            Value::Array(arr) => {
239                assert_eq!(arr.len(), 3);
240                assert_eq!(arr[0], Value::String("dev".to_string()));
241            }
242            _ => panic!("expected array"),
243        }
244    }
245
246    #[test]
247    fn test_coerce_values_empty_error() {
248        assert!(coerce_values(&[]).is_err());
249    }
250}