Skip to main content

agent_first_data/document/
coerce.rs

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