Skip to main content

turnframe_core/operation/
argument.rs

1//! One argument of an operation: what people call it, where its value comes from, and
2//! the shape a model gives it.
3
4use serde::{Deserialize, Serialize};
5
6use crate::ids::{ReadToolKey, WorkflowKey};
7use crate::locale::Locale;
8use crate::operation::value::DateDirection;
9
10/// Where an argument's value comes from.
11#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
12#[serde(tag = "kind", rename_all = "snake_case")]
13#[non_exhaustive]
14pub enum ArgumentSource {
15    /// The user states it; a verifier checks they did.
16    #[default]
17    User,
18    /// The model may deduce it from what the user said; a verifier checks it is consistent.
19    Inferred,
20    /// Code fills it from a declared read after the plan exists. The model never sees it.
21    Server {
22        /// The read that supplies it.
23        read: ReadToolKey,
24    },
25}
26
27/// The shape a model gives an argument's value, derived from its JSON Schema.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(tag = "kind", rename_all = "snake_case")]
30#[non_exhaustive]
31pub enum ValueShape {
32    /// Text. A user-sourced text is pointed at in the user's words unless `written`.
33    Text {
34        /// The model may phrase it; otherwise it selects the user's own words.
35        written: bool,
36    },
37    /// One of a closed set of values.
38    Enum {
39        /// The values.
40        values: Vec<String>,
41    },
42    /// A whole number.
43    Integer,
44    /// A number.
45    Number,
46    /// True or false.
47    Bool,
48    /// A calendar date, given as a date expression code evaluates.
49    Date {
50        /// Which way a date without a year points.
51        direction: DateDirection,
52    },
53    /// An amount of money, given as a decimal and a currency code evaluates.
54    Money,
55    /// A record of a workflow, chosen among the records in view.
56    Record {
57        /// The workflow the record belongs to.
58        workflow: WorkflowKey,
59    },
60    /// Anything else, written against its own schema.
61    Structured,
62}
63
64/// A word people use for an argument, optionally only in one language.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct ArgumentLabel {
67    /// The language, or `None` for every language.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub locale: Option<Locale>,
70    /// The word.
71    pub text: String,
72}
73
74/// One argument of an operation.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[non_exhaustive]
77pub struct ArgumentSpec {
78    /// Its name among the arguments' top-level fields.
79    pub name: String,
80    /// What it is, from the argument type's documentation unless replaced.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub description: Option<String>,
83    /// The words people use for it. Hints for the model, never matched by code.
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub labels: Vec<ArgumentLabel>,
86    /// Whether an act cannot run without it.
87    pub required: bool,
88    /// Where its value comes from.
89    pub source: ArgumentSource,
90    /// The shape a model gives its value.
91    pub shape: ValueShape,
92    /// Whether its value names the record the operation creates, for the acts of the
93    /// same turn that point at that record before it has a label.
94    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
95    pub names_the_record: bool,
96}
97
98impl ArgumentSpec {
99    /// An argument named `name` of `shape`, user-sourced and optional.
100    #[must_use]
101    pub fn new(name: impl Into<String>, shape: ValueShape) -> Self {
102        Self {
103            name: name.into(),
104            description: None,
105            labels: Vec::new(),
106            required: false,
107            source: ArgumentSource::User,
108            shape,
109            names_the_record: false,
110        }
111    }
112
113    /// Adds a word people use for it, in every language.
114    #[must_use]
115    pub fn label(mut self, text: impl Into<String>) -> Self {
116        self.labels.push(ArgumentLabel {
117            locale: None,
118            text: text.into(),
119        });
120        self
121    }
122
123    /// Adds a word people use for it in `locale`.
124    #[must_use]
125    pub fn label_in(mut self, locale: impl Into<Locale>, text: impl Into<String>) -> Self {
126        self.labels.push(ArgumentLabel {
127            locale: Some(locale.into()),
128            text: text.into(),
129        });
130        self
131    }
132
133    /// Replaces its description.
134    #[must_use]
135    pub fn describe(mut self, description: impl Into<String>) -> Self {
136        self.description = Some(description.into());
137        self
138    }
139
140    /// Makes it required.
141    #[must_use]
142    pub const fn required(mut self) -> Self {
143        self.required = true;
144        self
145    }
146
147    /// Says its value names the record the operation creates.
148    #[must_use]
149    pub const fn names_the_record(mut self) -> Self {
150        self.names_the_record = true;
151        self
152    }
153
154    /// Makes it optional.
155    #[must_use]
156    pub const fn optional(mut self) -> Self {
157        self.required = false;
158        self
159    }
160
161    /// Lets the model deduce it from what the user said.
162    #[must_use]
163    pub fn inferred(mut self) -> Self {
164        self.source = ArgumentSource::Inferred;
165        self
166    }
167
168    /// Fills it from `read` after the plan exists.
169    #[must_use]
170    pub fn from_read(mut self, read: impl Into<ReadToolKey>) -> Self {
171        self.source = ArgumentSource::Server { read: read.into() };
172        self
173    }
174
175    /// Lets the model phrase a text value instead of selecting the user's words.
176    #[must_use]
177    pub fn written(mut self) -> Self {
178        if let ValueShape::Text { written } = &mut self.shape {
179            *written = true;
180        }
181        self
182    }
183
184    /// Points a date without a year in `direction`.
185    #[must_use]
186    pub fn date_direction(mut self, direction: DateDirection) -> Self {
187        if let ValueShape::Date { direction: current } = &mut self.shape {
188            *current = direction;
189        }
190        self
191    }
192
193    /// Gives it as an amount of money.
194    #[must_use]
195    pub fn money(mut self) -> Self {
196        self.shape = ValueShape::Money;
197        self
198    }
199
200    /// Gives it as a record of `workflow`. The operation receives the record's
201    /// [`CaseRef`](crate::case::CaseRef) fields, with `label` when the record was in
202    /// view, or, for one the same turn opens, the value of the opening operation's
203    /// argument that [names it](Self::names_the_record). A record named but not in view is looked
204    /// up in the case directory, and asked for again when none or several match.
205    #[must_use]
206    pub fn record(mut self, workflow: impl Into<WorkflowKey>) -> Self {
207        self.shape = ValueShape::Record {
208            workflow: workflow.into(),
209        };
210        self
211    }
212
213    /// The labels that apply in `locale`: its own ones first, then the universal ones.
214    pub fn labels_for<'a>(&'a self, locale: &'a Locale) -> impl Iterator<Item = &'a str> + 'a {
215        let own = self
216            .labels
217            .iter()
218            .filter(move |label| label.locale.as_ref() == Some(locale));
219        let universal = self.labels.iter().filter(|label| label.locale.is_none());
220        own.chain(universal).map(|label| label.text.as_str())
221    }
222}
223
224/// Derives the shape of one property of an arguments schema.
225///
226/// `defs` is the schema's `$defs`, for properties that reference an enum there.
227#[must_use]
228pub fn shape_of(property: &serde_json::Value, defs: Option<&serde_json::Value>) -> ValueShape {
229    if let Some(value) = without_null(property) {
230        return shape_of(&value, defs);
231    }
232    let resolved = resolve_ref(property, defs).unwrap_or(property);
233    if let Some(values) = enum_values(resolved) {
234        return ValueShape::Enum { values };
235    }
236    let kind = resolved.get("type").and_then(serde_json::Value::as_str);
237    match kind {
238        Some("string")
239            if resolved.get("format").and_then(serde_json::Value::as_str) == Some("date") =>
240        {
241            ValueShape::Date {
242                direction: DateDirection::Any,
243            }
244        }
245        Some("string") => ValueShape::Text { written: false },
246        Some("integer") => ValueShape::Integer,
247        Some("number") => ValueShape::Number,
248        Some("boolean") => ValueShape::Bool,
249        _ => ValueShape::Structured,
250    }
251}
252
253/// An optional property's schema without the `null` that makes it optional, or `None`.
254fn without_null(property: &serde_json::Value) -> Option<serde_json::Value> {
255    let is_null = |kind: &serde_json::Value| kind.as_str() == Some("null");
256    if let Some(kinds) = property.get("type").and_then(serde_json::Value::as_array) {
257        let kept: Vec<&serde_json::Value> = kinds.iter().filter(|kind| !is_null(kind)).collect();
258        let [only] = kept.as_slice() else {
259            return None;
260        };
261        let mut value = property.clone();
262        value["type"] = (*only).clone();
263        return Some(value);
264    }
265    let branches = property
266        .get("anyOf")
267        .or_else(|| property.get("oneOf"))?
268        .as_array()?;
269    let kept: Vec<serde_json::Value> = branches
270        .iter()
271        .filter(|branch| !branch.get("type").is_some_and(is_null))
272        .cloned()
273        .collect();
274    match kept.as_slice() {
275        _ if kept.len() == branches.len() => None,
276        [only] => Some(only.clone()),
277        _ => Some(serde_json::json!({ "anyOf": kept })),
278    }
279}
280
281fn resolve_ref<'a>(
282    property: &'a serde_json::Value,
283    defs: Option<&'a serde_json::Value>,
284) -> Option<&'a serde_json::Value> {
285    let reference = property.get("$ref")?.as_str()?;
286    let name = reference.rsplit('/').next()?;
287    defs?.get(name)
288}
289
290fn enum_values(schema: &serde_json::Value) -> Option<Vec<String>> {
291    if let Some(values) = schema.get("enum").and_then(serde_json::Value::as_array) {
292        return values
293            .iter()
294            .map(|value| value.as_str().map(str::to_owned))
295            .collect();
296    }
297    let branches = schema
298        .get("oneOf")
299        .or_else(|| schema.get("anyOf"))?
300        .as_array()?;
301    branches
302        .iter()
303        .map(|branch| branch.get("const")?.as_str().map(str::to_owned))
304        .collect()
305}
306
307#[cfg(test)]
308mod tests {
309    use serde_json::json;
310
311    use super::*;
312
313    #[test]
314    fn an_optional_argument_has_the_shape_of_its_value() {
315        let defs = json!({"Kind": {"enum": ["a", "b"]}});
316        let text = ValueShape::Text { written: false };
317        assert_eq!(shape_of(&json!({"type": ["string", "null"]}), None), text);
318        assert_eq!(
319            shape_of(
320                &json!({"anyOf": [{"type": "string"}, {"type": "null"}]}),
321                None
322            ),
323            text
324        );
325        assert_eq!(
326            shape_of(
327                &json!({"anyOf": [{"$ref": "#/$defs/Kind"}, {"type": "null"}]}),
328                Some(&defs)
329            ),
330            ValueShape::Enum {
331                values: vec!["a".into(), "b".into()]
332            }
333        );
334        assert_eq!(
335            shape_of(&json!({"type": ["integer", "null"]}), None),
336            ValueShape::Integer
337        );
338    }
339
340    #[test]
341    fn shapes_follow_the_argument_types_schema() {
342        let defs = json!({"Kind": {"oneOf": [{"type": "string", "const": "a"}, {"type": "string", "const": "b"}]}});
343        assert_eq!(
344            shape_of(&json!({"type": "string"}), None),
345            ValueShape::Text { written: false }
346        );
347        assert_eq!(
348            shape_of(&json!({"type": "string", "format": "date"}), None),
349            ValueShape::Date {
350                direction: DateDirection::Any
351            }
352        );
353        assert_eq!(
354            shape_of(&json!({"$ref": "#/$defs/Kind"}), Some(&defs)),
355            ValueShape::Enum {
356                values: vec!["a".into(), "b".into()]
357            }
358        );
359        assert_eq!(
360            shape_of(&json!({"type": "integer"}), None),
361            ValueShape::Integer
362        );
363        assert_eq!(
364            shape_of(&json!({"type": "object"}), None),
365            ValueShape::Structured
366        );
367    }
368
369    #[test]
370    fn labels_in_the_turns_language_come_first() {
371        let spec = ArgumentSpec::new("value", ValueShape::Text { written: false })
372            .label("subject")
373            .label_in("it-IT", "oggetto");
374        let (it, en) = (Locale::from("it-IT"), Locale::from("en-GB"));
375        let italian: Vec<&str> = spec.labels_for(&it).collect();
376        assert_eq!(italian, vec!["oggetto", "subject"]);
377        let english: Vec<&str> = spec.labels_for(&en).collect();
378        assert_eq!(english, vec!["subject"]);
379    }
380}