Skip to main content

agent_first_data/document/
coerce.rs

1//! CLI-facing value construction for `set`/`add`: a bare VALUE/FIELD=VALUE is
2//! always [`Value::String`] with zero coercion — no shape-guessing, no type
3//! prefixes. An exact type is requested explicitly via [`ValueType`] (`set`'s
4//! `--value-type` flag); [`guard_bare_overwrite`] is the heterogeneous-overwrite
5//! guard that turns a bare VALUE silently rewriting what is already at the path
6//! — a scalar of another type, or a whole container — into an argument error
7//! instead.
8
9use crate::document::{DocumentError, DocumentResult, Value};
10
11/// The exact type an explicit `--value-type` requests for a `set` VALUE.
12///
13/// `String` is also the *implicit* type of a bare VALUE (zero coercion) —
14/// see [`guard_bare_overwrite`] for the rule that keeps that implicit choice
15/// from silently overwriting a differently-typed existing scalar.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ValueType {
18    String,
19    Number,
20    Bool,
21    Null,
22    Json,
23}
24
25impl ValueType {
26    /// Parse a `--value-type` flag value. `None` for anything else.
27    pub fn parse(name: &str) -> Option<Self> {
28        match name {
29            "string" => Some(Self::String),
30            "number" => Some(Self::Number),
31            "bool" => Some(Self::Bool),
32            "null" => Some(Self::Null),
33            "json" => Some(Self::Json),
34            _ => None,
35        }
36    }
37
38    /// The flag spelling this variant was parsed from (used in error
39    /// messages and the heterogeneous-overwrite guard's escape hatches).
40    pub fn name(self) -> &'static str {
41        match self {
42            Self::String => "string",
43            Self::Number => "number",
44            Self::Bool => "bool",
45            Self::Null => "null",
46            Self::Json => "json",
47        }
48    }
49}
50
51/// Construct a `Value` from a CLI string per an explicit [`ValueType`].
52///
53/// `raw` is the VALUE positional; it must be `None` for [`ValueType::Null`]
54/// (null takes no payload) and `Some` for every other type. `number`/`bool`
55/// parse strictly (an invalid literal is a [`DocumentError::ParseError`],
56/// not a silent fallback to string); `json` is the only entry point for
57/// arrays, objects, and an "exact-type scalar" (`--value-type json` value
58/// `"8080"` writes the *string* `"8080"`, not the number). `number` is
59/// literal-faithful: an oversized integer or high-precision float is
60/// preserved digit for digit via [`Value::Number`].
61pub fn value_from_type(value_type: ValueType, raw: Option<&str>) -> DocumentResult<Value> {
62    match value_type {
63        ValueType::Null => match raw {
64            None => Ok(Value::Null),
65            Some(_) => Err(DocumentError::ParseError {
66                format: "value".to_string(),
67                detail: "--value-type null takes no VALUE".to_string(),
68            }),
69        },
70        ValueType::String => Ok(Value::String(require_value(raw, value_type)?.to_string())),
71        ValueType::Bool => {
72            let raw = require_value(raw, value_type)?;
73            parse_bool(raw).map(Value::Bool).ok_or_else(|| {
74                DocumentError::ParseError {
75                    format: "boolean".to_string(),
76                    detail: format!(
77                        "invalid --value-type bool value `{raw}`; expected true/false, yes/no, on/off, or 1/0"
78                    ),
79                }
80            })
81        }
82        ValueType::Number => parse_number_literal(require_value(raw, value_type)?),
83        ValueType::Json => {
84            let raw = require_value(raw, value_type)?;
85            serde_json::from_str::<serde_json::Value>(raw)
86                .map(Value::from)
87                .map_err(|error| DocumentError::ParseError {
88                    format: "JSON".to_string(),
89                    detail: error.to_string(),
90                })
91        }
92    }
93}
94
95fn require_value(raw: Option<&str>, value_type: ValueType) -> DocumentResult<&str> {
96    raw.ok_or_else(|| DocumentError::ParseError {
97        format: "value".to_string(),
98        detail: format!("--value-type {} requires a VALUE", value_type.name()),
99    })
100}
101
102/// Parse a `--value-type number` literal strictly as a JSON number (no
103/// leading zeros, no `+` sign, no `Infinity`/`NaN` — the same grammar the
104/// document layer's JSON reader accepts), preserving its exact digits via
105/// [`Value::Number`] when it does not fit `Integer`/`Unsigned`/`Float`
106/// exactly. Reuses `serde_json`'s `arbitrary_precision` parsing so the
107/// validation and the literal-fidelity capture are the same code path as
108/// the JSON document reader (`format::json::load`).
109fn parse_number_literal(text: &str) -> DocumentResult<Value> {
110    serde_json::from_str::<serde_json::Value>(text)
111        .ok()
112        .filter(serde_json::Value::is_number)
113        .map(Value::from)
114        .ok_or_else(|| DocumentError::ParseError {
115            format: "number".to_string(),
116            detail: format!("invalid --value-type number literal `{text}`"),
117        })
118}
119
120fn parse_bool(value: &str) -> Option<bool> {
121    match value.to_ascii_lowercase().as_str() {
122        "true" | "yes" | "on" | "1" => Some(true),
123        "false" | "no" | "off" | "0" => Some(false),
124        _ => None,
125    }
126}
127
128/// The four AFDATA scalar "kinds" the heterogeneous-overwrite guard
129/// distinguishes. `Number` covers `Integer`/`Unsigned`/`Float`/
130/// [`Value::Number`] — the guard cares whether VALUE would change a scalar
131/// *from* one of these kinds *to* a bare string, not which numeric
132/// representation was in play.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum ScalarKind {
135    Null,
136    Bool,
137    Number,
138    String,
139}
140
141impl ScalarKind {
142    /// The `--value-type` spelling that keeps a value of this kind.
143    pub fn value_type_name(self) -> &'static str {
144        match self {
145            Self::Null => "null",
146            Self::Bool => "bool",
147            Self::Number => "number",
148            Self::String => "string",
149        }
150    }
151}
152
153/// Whether `value` satisfies `expected` for a typed read — the GET-side
154/// counterpart to [`value_from_type`]. `Json` matches any value; `Number`
155/// matches every numeric representation; the rest match their one kind. A
156/// consumer that asks for a type gets a value back only when it actually is
157/// that type, so a wrong-typed leaf is a caught error rather than a surprise.
158pub fn value_matches_type(value: &Value, expected: ValueType) -> bool {
159    match expected {
160        ValueType::Json => true,
161        ValueType::String => matches!(value, Value::String(_)),
162        ValueType::Bool => matches!(value, Value::Bool(_)),
163        ValueType::Null => matches!(value, Value::Null),
164        ValueType::Number => matches!(
165            value,
166            Value::Integer(_) | Value::Unsigned(_) | Value::Float(_) | Value::Number(_)
167        ),
168    }
169}
170
171/// The scalar kind of `value`, or `None` for an array/object.
172///
173/// Containers have no scalar kind; [`guard_bare_overwrite`] reports them
174/// separately as [`BareOverwrite::Container`] rather than exempting them.
175pub fn scalar_kind(value: &Value) -> Option<ScalarKind> {
176    match value {
177        Value::Null => Some(ScalarKind::Null),
178        Value::Bool(_) => Some(ScalarKind::Bool),
179        Value::Integer(_) | Value::Unsigned(_) | Value::Float(_) | Value::Number(_) => {
180            Some(ScalarKind::Number)
181        }
182        Value::String(_) => Some(ScalarKind::String),
183        Value::Array(_) | Value::Object(_) => None,
184    }
185}
186
187/// What a bare VALUE would overwrite, and the `--value-type` that would keep
188/// it — see [`guard_bare_overwrite`].
189#[derive(Clone, Copy, Debug, PartialEq, Eq)]
190pub enum BareOverwrite {
191    /// A scalar of a different kind: the type changes.
192    Scalar(ScalarKind),
193    /// A whole array or object: the container is discarded.
194    Container(&'static str),
195}
196
197impl BareOverwrite {
198    /// The kind name to show the caller.
199    pub fn found(self) -> &'static str {
200        match self {
201            Self::Scalar(kind) => kind.value_type_name(),
202            Self::Container(name) => name,
203        }
204    }
205
206    /// The `--value-type` spelling that preserves what is already there.
207    ///
208    /// A container has no scalar type to keep, so preserving it means writing
209    /// it back as a JSON literal.
210    pub fn keeps(self) -> &'static str {
211        match self {
212            Self::Scalar(kind) => kind.value_type_name(),
213            Self::Container(_) => "json",
214        }
215    }
216}
217
218/// The heterogeneous-overwrite guard, which closes the "no silent type
219/// rewrites" rule: a bare VALUE (implicit `--value-type
220/// string`) is always a string, so writing one over anything that is not
221/// already a string rewrites what is there. That is an argument error, not a
222/// coercion decision. Returns what would be overwritten so the caller can name
223/// both escape hatches (`--value-type <kind>` to keep it, `--value-type
224/// string` to convert deliberately).
225///
226/// Containers are guarded too, and are the reason this returns more than a
227/// [`ScalarKind`]: `set config.json deps hello` over a three-element array
228/// used to exit 0 and leave `"deps": "hello"` behind. A type change at least
229/// keeps one value; discarding a container loses everything under it, with no
230/// signal at all.
231///
232/// `Ok(())` only when there is nothing to guard: the target is absent (a new
233/// key) or already a string. Never called when `--value-type` was passed
234/// explicitly — an explicit type is a deliberate declaration, not a silent
235/// rewrite.
236pub fn guard_bare_overwrite(existing: Option<&Value>) -> Result<(), BareOverwrite> {
237    match existing {
238        None | Some(Value::String(_)) => Ok(()),
239        Some(Value::Array(_)) => Err(BareOverwrite::Container("array")),
240        Some(Value::Object(_)) => Err(BareOverwrite::Container("object")),
241        Some(other) => match scalar_kind(other) {
242            Some(kind) => Err(BareOverwrite::Scalar(kind)),
243            None => Ok(()),
244        },
245    }
246}
247
248/// Coerce a CLI string toward the type already present at `existing`, for a
249/// consumer that *knows* the target type from the value it is replacing (a
250/// config setter reading typed leaves out of a serialized document).
251///
252/// This is the library counterpart to the CLI's explicit `--value-type`: the
253/// generic `afdata` CLI must ask the user for the type because it cannot know
254/// it, but a consumer that does — because it holds the existing typed leaf, or
255/// its own schema — should neither add a flag nor re-parse. It is **type
256/// directed, not shape guessing**: the existing leaf's [`scalar_kind`] selects
257/// the parse; a `bool`/`number` literal that does not match falls back to a
258/// string; a string, `null`, or absent leaf yields a string; a container leaf
259/// is replaced with a JSON literal.
260pub fn coerce_toward(raw: &str, existing: Option<&Value>) -> DocumentResult<Value> {
261    match existing.and_then(scalar_kind) {
262        Some(ScalarKind::Bool) => Ok(value_from_type(ValueType::Bool, Some(raw))
263            .unwrap_or_else(|_| Value::String(raw.to_string()))),
264        Some(ScalarKind::Number) => Ok(value_from_type(ValueType::Number, Some(raw))
265            .unwrap_or_else(|_| Value::String(raw.to_string()))),
266        Some(ScalarKind::String | ScalarKind::Null) => Ok(Value::String(raw.to_string())),
267        // A container leaf (array/object) or a brand-new key with no type to
268        // aim at: a structured literal (`[`/`{`) is parsed as JSON so the value
269        // round-trips, while a bare scalar stays a string — no scalar
270        // shape-guessing (`007` never becomes `7`).
271        None => coerce_structured_or_string(raw),
272    }
273}
274
275/// A structured literal (`[`/`{`) parses as a JSON array/object; anything else
276/// is taken verbatim as a string. Used where no scalar type is known, so a
277/// bare value is never shape-guessed into a number/bool.
278fn coerce_structured_or_string(raw: &str) -> DocumentResult<Value> {
279    let trimmed = raw.trim_start();
280    if trimmed.starts_with('[') || trimmed.starts_with('{') {
281        value_from_type(ValueType::Json, Some(raw))
282    } else {
283        Ok(Value::String(raw.to_string()))
284    }
285}
286
287/// Coerce a CLI value slice toward the type at `existing` via [`coerce_toward`]:
288/// one value becomes a scalar, several become an array whose elements are each
289/// coerced toward the existing array's element type. An empty slice is an error.
290pub fn coerce_values_toward(values: &[String], existing: Option<&Value>) -> DocumentResult<Value> {
291    match values {
292        [] => Err(DocumentError::EmptyValues),
293        [one] => coerce_toward(one, existing),
294        many => {
295            let element = existing
296                .and_then(Value::as_array)
297                .and_then(|array| array.first());
298            Ok(Value::Array(
299                many.iter()
300                    .map(|value| coerce_toward(value, element))
301                    .collect::<DocumentResult<Vec<_>>>()?,
302            ))
303        }
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    #![allow(clippy::unwrap_used, clippy::panic)]
310    use super::*;
311
312    #[test]
313    fn bare_string_is_zero_coercion() {
314        for raw in ["007", "1.0", "true", "null", "3e10"] {
315            assert_eq!(
316                value_from_type(ValueType::String, Some(raw)).unwrap(),
317                Value::String(raw.to_string())
318            );
319        }
320    }
321
322    #[test]
323    fn value_type_number_is_literal_faithful() {
324        assert_eq!(
325            value_from_type(ValueType::Number, Some("18446744073709551615")).unwrap(),
326            Value::Unsigned(u64::MAX)
327        );
328        let huge = "123456789012345678901234567890";
329        assert_eq!(
330            value_from_type(ValueType::Number, Some(huge)).unwrap(),
331            Value::Number(huge.to_string())
332        );
333        let precise = "0.1000000000000000055511151231257827";
334        assert_eq!(
335            value_from_type(ValueType::Number, Some(precise)).unwrap(),
336            Value::Number(precise.to_string())
337        );
338    }
339
340    #[test]
341    fn value_type_number_rejects_leading_zero_and_non_numeric() {
342        assert!(value_from_type(ValueType::Number, Some("007")).is_err());
343        assert!(value_from_type(ValueType::Number, Some("abc")).is_err());
344        assert!(value_from_type(ValueType::Number, Some("+5")).is_err());
345    }
346
347    #[test]
348    fn value_type_bool_is_lenient() {
349        assert_eq!(
350            value_from_type(ValueType::Bool, Some("yes")).unwrap(),
351            Value::Bool(true)
352        );
353        assert!(value_from_type(ValueType::Bool, Some("nope")).is_err());
354    }
355
356    #[test]
357    fn value_type_null_takes_no_value() {
358        assert_eq!(value_from_type(ValueType::Null, None).unwrap(), Value::Null);
359        assert!(value_from_type(ValueType::String, None).is_err());
360        assert!(value_from_type(ValueType::Null, Some("x")).is_err());
361    }
362
363    #[test]
364    fn value_type_json_is_the_only_container_entry_point() {
365        let value = value_from_type(ValueType::Json, Some(r#"["a","b"]"#)).unwrap();
366        assert_eq!(
367            value,
368            Value::Array(vec![
369                Value::String("a".to_string()),
370                Value::String("b".to_string())
371            ])
372        );
373        // An exact-type scalar via --value-type json: the string "8080", not
374        // the number 8080.
375        assert_eq!(
376            value_from_type(ValueType::Json, Some("\"8080\"")).unwrap(),
377            Value::String("8080".to_string())
378        );
379    }
380
381    #[test]
382    fn guard_fires_for_any_bare_overwrite_that_is_not_string_to_string() {
383        assert_eq!(guard_bare_overwrite(None), Ok(()));
384        assert_eq!(
385            guard_bare_overwrite(Some(&Value::String("x".to_string()))),
386            Ok(())
387        );
388        // A container is no longer exempt: it used to pass here, which is how
389        // `set config.json deps hello` discarded a whole array at exit 0.
390        assert_eq!(
391            guard_bare_overwrite(Some(&Value::Array(vec![]))),
392            Err(BareOverwrite::Container("array"))
393        );
394        assert_eq!(
395            guard_bare_overwrite(Some(&Value::Integer(8080))),
396            Err(BareOverwrite::Scalar(ScalarKind::Number))
397        );
398        assert_eq!(
399            guard_bare_overwrite(Some(&Value::Bool(true))),
400            Err(BareOverwrite::Scalar(ScalarKind::Bool))
401        );
402        assert_eq!(
403            guard_bare_overwrite(Some(&Value::Null)),
404            Err(BareOverwrite::Scalar(ScalarKind::Null))
405        );
406    }
407
408    #[test]
409    fn coerce_toward_is_type_directed_not_shape_guessing() {
410        // Toward an existing scalar's kind: bool/number parse toward it, a
411        // non-matching literal falls back to a string, a string leaf stays a
412        // string.
413        assert_eq!(
414            coerce_toward("false", Some(&Value::Bool(true))).unwrap(),
415            Value::Bool(false)
416        );
417        assert_eq!(
418            coerce_toward("5432", Some(&Value::Integer(1))).unwrap(),
419            Value::from(serde_json::json!(5432))
420        );
421        assert_eq!(
422            coerce_toward("not-a-number", Some(&Value::Integer(1))).unwrap(),
423            Value::String("not-a-number".to_string())
424        );
425        assert_eq!(
426            coerce_toward("007", Some(&Value::String("x".to_string()))).unwrap(),
427            Value::String("007".to_string())
428        );
429        // No existing type: a bare scalar is a string (no `007` -> `7`), but a
430        // structured literal parses as JSON.
431        assert_eq!(
432            coerce_toward("007", None).unwrap(),
433            Value::String("007".to_string())
434        );
435        assert!(matches!(
436            coerce_toward("[]", None).unwrap(),
437            Value::Array(_)
438        ));
439        assert!(matches!(
440            coerce_toward("{\"a\":1}", Some(&Value::Object(Default::default()))).unwrap(),
441            Value::Object(_)
442        ));
443    }
444
445    #[test]
446    fn coerce_values_toward_scalar_vs_array() {
447        assert!(matches!(
448            coerce_values_toward(&[], None),
449            Err(DocumentError::EmptyValues)
450        ));
451        assert_eq!(
452            coerce_values_toward(&["x".to_string()], None).unwrap(),
453            Value::String("x".to_string())
454        );
455        // Several values become an array, each coerced toward the existing
456        // array's element type.
457        let existing = Value::Array(vec![Value::Bool(true)]);
458        assert_eq!(
459            coerce_values_toward(&["false".to_string(), "true".to_string()], Some(&existing))
460                .unwrap(),
461            Value::Array(vec![Value::Bool(false), Value::Bool(true)])
462        );
463    }
464}
465
466#[cfg(test)]
467mod bare_overwrite_tests {
468    use super::*;
469    use std::collections::BTreeMap;
470
471    #[test]
472    fn containers_are_guarded_and_ask_for_json() {
473        let array = Value::Array(vec![Value::String("a".to_string())]);
474        let object = Value::Object(BTreeMap::new());
475        for (value, name) in [(&array, "array"), (&object, "object")] {
476            let overwrite = guard_bare_overwrite(Some(value)).unwrap_err();
477            assert_eq!(overwrite.found(), name);
478            // A container has no scalar type to keep; JSON is how it survives.
479            assert_eq!(overwrite.keeps(), "json");
480        }
481    }
482
483    #[test]
484    fn scalars_keep_naming_their_own_type() {
485        let overwrite = guard_bare_overwrite(Some(&Value::Integer(8080))).unwrap_err();
486        assert_eq!(overwrite, BareOverwrite::Scalar(ScalarKind::Number));
487        assert_eq!(overwrite.keeps(), "number");
488    }
489
490    #[test]
491    fn a_new_key_or_an_existing_string_is_not_a_rewrite() {
492        assert!(guard_bare_overwrite(None).is_ok());
493        assert!(guard_bare_overwrite(Some(&Value::String("old".to_string()))).is_ok());
494    }
495}