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/// The scalar kind of `value`, or `None` for an array/object (the guard
156/// does not apply to containers — see `cli-shell-config-todo.md` §3).
157pub fn scalar_kind(value: &Value) -> Option<ScalarKind> {
158    match value {
159        Value::Null => Some(ScalarKind::Null),
160        Value::Bool(_) => Some(ScalarKind::Bool),
161        Value::Integer(_) | Value::Unsigned(_) | Value::Float(_) | Value::Number(_) => {
162            Some(ScalarKind::Number)
163        }
164        Value::String(_) => Some(ScalarKind::String),
165        Value::Array(_) | Value::Object(_) => None,
166    }
167}
168
169/// The §3 "异型覆盖守卫" (heterogeneous-overwrite guard, closing design rule
170/// 4 — "no silent type rewrites"): a bare VALUE (implicit `--value-type
171/// string`) is always a string, so overwriting an *existing scalar of a
172/// different kind* would silently change its type. That is an argument
173/// error, not a coercion decision. Returns the existing kind so the caller
174/// can build a message with the two escape hatches (`--value-type <kind>`
175/// to keep the type, or `--value-type string` to convert explicitly).
176///
177/// `Ok(())` when there is nothing to guard: the target is absent (a new
178/// key), already a string (no type change), or a container (out of this
179/// guard's scope). Never called when `--value-type` was passed explicitly —
180/// an explicit type is a deliberate declaration, not a silent rewrite.
181pub fn guard_bare_overwrite(existing: Option<&Value>) -> Result<(), ScalarKind> {
182    match existing.and_then(scalar_kind) {
183        Some(ScalarKind::String) | None => Ok(()),
184        Some(other) => Err(other),
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    #![allow(clippy::unwrap_used, clippy::panic)]
191    use super::*;
192
193    #[test]
194    fn bare_string_is_zero_coercion() {
195        for raw in ["007", "1.0", "true", "null", "3e10"] {
196            assert_eq!(
197                value_from_type(ValueType::String, Some(raw)).unwrap(),
198                Value::String(raw.to_string())
199            );
200        }
201    }
202
203    #[test]
204    fn value_type_number_is_literal_faithful() {
205        assert_eq!(
206            value_from_type(ValueType::Number, Some("18446744073709551615")).unwrap(),
207            Value::Unsigned(u64::MAX)
208        );
209        let huge = "123456789012345678901234567890";
210        assert_eq!(
211            value_from_type(ValueType::Number, Some(huge)).unwrap(),
212            Value::Number(huge.to_string())
213        );
214        let precise = "0.1000000000000000055511151231257827";
215        assert_eq!(
216            value_from_type(ValueType::Number, Some(precise)).unwrap(),
217            Value::Number(precise.to_string())
218        );
219    }
220
221    #[test]
222    fn value_type_number_rejects_leading_zero_and_non_numeric() {
223        assert!(value_from_type(ValueType::Number, Some("007")).is_err());
224        assert!(value_from_type(ValueType::Number, Some("abc")).is_err());
225        assert!(value_from_type(ValueType::Number, Some("+5")).is_err());
226    }
227
228    #[test]
229    fn value_type_bool_is_lenient() {
230        assert_eq!(
231            value_from_type(ValueType::Bool, Some("yes")).unwrap(),
232            Value::Bool(true)
233        );
234        assert!(value_from_type(ValueType::Bool, Some("nope")).is_err());
235    }
236
237    #[test]
238    fn value_type_null_takes_no_value() {
239        assert_eq!(value_from_type(ValueType::Null, None).unwrap(), Value::Null);
240        assert!(value_from_type(ValueType::String, None).is_err());
241        assert!(value_from_type(ValueType::Null, Some("x")).is_err());
242    }
243
244    #[test]
245    fn value_type_json_is_the_only_container_entry_point() {
246        let value = value_from_type(ValueType::Json, Some(r#"["a","b"]"#)).unwrap();
247        assert_eq!(
248            value,
249            Value::Array(vec![
250                Value::String("a".to_string()),
251                Value::String("b".to_string())
252            ])
253        );
254        // An exact-type scalar via --value-type json: the string "8080", not
255        // the number 8080.
256        assert_eq!(
257            value_from_type(ValueType::Json, Some("\"8080\"")).unwrap(),
258            Value::String("8080".to_string())
259        );
260    }
261
262    #[test]
263    fn guard_fires_only_for_bare_overwrite_of_a_differently_kinded_scalar() {
264        assert_eq!(guard_bare_overwrite(None), Ok(()));
265        assert_eq!(
266            guard_bare_overwrite(Some(&Value::String("x".to_string()))),
267            Ok(())
268        );
269        assert_eq!(guard_bare_overwrite(Some(&Value::Array(vec![]))), Ok(()));
270        assert_eq!(
271            guard_bare_overwrite(Some(&Value::Integer(8080))),
272            Err(ScalarKind::Number)
273        );
274        assert_eq!(
275            guard_bare_overwrite(Some(&Value::Bool(true))),
276            Err(ScalarKind::Bool)
277        );
278        assert_eq!(
279            guard_bare_overwrite(Some(&Value::Null)),
280            Err(ScalarKind::Null)
281        );
282    }
283}