Skip to main content

turnframe_core/operation/
mod.rs

1//! What a workflow offers to do: operations, their arguments, and examples of each.
2//!
3//! An [`OperationSpec`] is built once per view with a builder, so a field added later
4//! breaks nobody's code. Its arguments come from a typed arguments struct, and each can
5//! be given labels, a source and a shape. Examples show a message and the arguments it
6//! carries, or the arguments it leaves out; [`OperationSpec::validate`] checks every
7//! example against the arguments type when the registry is built, so an example cannot
8//! go stale unnoticed.
9
10mod argument;
11mod value;
12
13use std::collections::BTreeMap;
14use std::fmt;
15use std::sync::Arc;
16
17use schemars::{JsonSchema, Schema};
18use serde::de::DeserializeOwned;
19use serde::{Deserialize, Serialize};
20
21use crate::ids::{OperationKey, ReadToolKey, WorkflowKey};
22use crate::locale::Locale;
23use crate::plan::{ActAvailability, ActMutability, TargetPolicy};
24
25pub use argument::{ArgumentLabel, ArgumentSource, ArgumentSpec, ValueShape, shape_of};
26pub use value::{
27    DateDirection, DateError, DateExpr, DatePeriod, DateUnit, DayOfWeek, Money, MoneyError,
28    PeriodOccurrence, WeekdayOccurrence,
29};
30
31type Validator = Arc<dyn Fn(&serde_json::Value) -> Result<(), String> + Send + Sync>;
32
33/// A word a workflow's users say, and what it means there.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct GlossaryTerm {
36    /// The word, as users say it.
37    pub term: String,
38    /// What it means in this workflow.
39    pub meaning: String,
40}
41
42impl GlossaryTerm {
43    /// A term and its meaning.
44    #[must_use]
45    pub fn new(term: impl Into<String>, meaning: impl Into<String>) -> Self {
46        Self {
47            term: term.into(),
48            meaning: meaning.into(),
49        }
50    }
51}
52
53/// A message and what it means for one operation, shown to the model as an example.
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55#[non_exhaustive]
56pub struct OperationExample {
57    /// What a user wrote.
58    pub message: String,
59    /// The arguments it gives, by name.
60    #[serde(default)]
61    pub arguments: serde_json::Map<String, serde_json::Value>,
62    /// The arguments it names or implies without giving a value.
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub not_given: Vec<String>,
65}
66
67/// Why an operation's declaration cannot be used.
68#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
69#[error("operation {operation}: {reason}")]
70pub struct OperationSpecError {
71    /// The operation.
72    pub operation: OperationKey,
73    /// What is wrong with its declaration.
74    pub reason: String,
75}
76
77/// One operation a workflow offers in a view.
78#[derive(Clone, Serialize, Deserialize)]
79#[non_exhaustive]
80pub struct OperationSpec {
81    /// Its key, unique across the registry.
82    pub key: OperationKey,
83    /// The workflow offering it; the registry fills it in.
84    pub workflow: WorkflowKey,
85    /// One line saying what it does, for choosing among operations.
86    pub summary: String,
87    /// The summary in other languages, by locale; the one above serves every other.
88    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
89    pub summaries: BTreeMap<Locale, String>,
90    /// Longer guidance for filling its arguments.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub guidance: Option<String>,
93    /// Which records it may aim at.
94    pub target_policy: TargetPolicy,
95    /// Whether it changes a record.
96    pub mutability: ActMutability,
97    /// Whether a model may propose it, or only a card.
98    pub availability: ActAvailability,
99    /// The JSON Schema of its arguments type.
100    pub arguments_schema: Schema,
101    /// Its arguments, in the schema's order.
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub arguments: Vec<ArgumentSpec>,
104    /// Examples of messages and what they give.
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub examples: Vec<OperationExample>,
107    /// Reads whose results the argument filler is to be shown; declared, not yet run.
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub context_reads: Vec<ReadToolKey>,
110    #[serde(skip)]
111    validator: Option<Validator>,
112    #[serde(skip)]
113    problems: Vec<String>,
114}
115
116impl fmt::Debug for OperationSpec {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        f.debug_struct("OperationSpec")
119            .field("key", &self.key)
120            .field("workflow", &self.workflow)
121            .field("summary", &self.summary)
122            .field("target_policy", &self.target_policy)
123            .field("arguments", &self.arguments)
124            .field("examples", &self.examples.len())
125            .finish_non_exhaustive()
126    }
127}
128
129impl PartialEq for OperationSpec {
130    fn eq(&self, other: &Self) -> bool {
131        self.key == other.key
132            && self.workflow == other.workflow
133            && self.summary == other.summary
134            && self.summaries == other.summaries
135            && self.guidance == other.guidance
136            && self.target_policy == other.target_policy
137            && self.mutability == other.mutability
138            && self.availability == other.availability
139            && self.arguments_schema == other.arguments_schema
140            && self.arguments == other.arguments
141            && self.examples == other.examples
142            && self.context_reads == other.context_reads
143    }
144}
145
146impl OperationSpec {
147    /// An operation named `key` that takes no arguments and changes an existing record.
148    #[must_use]
149    pub fn new(key: impl Into<OperationKey>) -> Self {
150        Self {
151            key: key.into(),
152            workflow: WorkflowKey::from(""),
153            summary: String::new(),
154            summaries: BTreeMap::new(),
155            guidance: None,
156            target_policy: TargetPolicy::RequiresExistingCase,
157            mutability: ActMutability::Mutating,
158            availability: ActAvailability::Proposable,
159            arguments_schema: schemars::schema_for!(()),
160            arguments: Vec::new(),
161            examples: Vec::new(),
162            context_reads: Vec::new(),
163            validator: None,
164            problems: Vec::new(),
165        }
166    }
167
168    /// Says in one line what it does.
169    #[must_use]
170    pub fn summary(mut self, summary: impl Into<String>) -> Self {
171        self.summary = summary.into();
172        self
173    }
174
175    /// Says what it does in `locale`'s language, for a turn in that language.
176    #[must_use]
177    pub fn summary_in(mut self, locale: impl Into<Locale>, summary: impl Into<String>) -> Self {
178        self.summaries.insert(locale.into(), summary.into());
179        self
180    }
181
182    /// Its summary for a turn in `locale`: that locale's, then its language's, then the
183    /// summary every other language reads.
184    #[must_use]
185    pub fn summary_for(&self, locale: &Locale) -> &str {
186        self.summaries
187            .get(locale)
188            .or_else(|| {
189                self.summaries
190                    .iter()
191                    .find(|(candidate, _)| candidate.same_language(locale))
192                    .map(|(_, summary)| summary)
193            })
194            .map_or(self.summary.as_str(), String::as_str)
195    }
196
197    /// Adds guidance for filling its arguments.
198    #[must_use]
199    pub fn guidance(mut self, guidance: impl Into<String>) -> Self {
200        self.guidance = Some(guidance.into());
201        self
202    }
203
204    /// Sets which records it may aim at.
205    #[must_use]
206    pub const fn target(mut self, policy: TargetPolicy) -> Self {
207        self.target_policy = policy;
208        self
209    }
210
211    /// Marks it as changing nothing.
212    #[must_use]
213    pub const fn read_only(mut self) -> Self {
214        self.mutability = ActMutability::ReadOnly;
215        self
216    }
217
218    /// Marks it as changing a record, which is the default.
219    #[must_use]
220    pub const fn mutating(mut self) -> Self {
221        self.mutability = ActMutability::Mutating;
222        self
223    }
224
225    /// Lets only a card run it.
226    #[must_use]
227    pub const fn card_only(mut self) -> Self {
228        self.availability = ActAvailability::CardOnly;
229        self
230    }
231
232    /// Takes its arguments from `A`, deriving one [`ArgumentSpec`] per top-level field.
233    #[must_use]
234    pub fn arguments<A: JsonSchema + DeserializeOwned + 'static>(mut self) -> Self {
235        self.arguments_schema = schemars::schema_for!(A);
236        self.arguments = derive_arguments(&self.arguments_schema);
237        self.validator = Some(Arc::new(|value| {
238            serde_json::from_value::<A>(value.clone())
239                .map(|_| ())
240                .map_err(|error| error.to_string())
241        }));
242        self
243    }
244
245    /// Adjusts the argument named `name`.
246    #[must_use]
247    pub fn argument(
248        mut self,
249        name: &str,
250        adjust: impl FnOnce(ArgumentSpec) -> ArgumentSpec,
251    ) -> Self {
252        match self
253            .arguments
254            .iter()
255            .position(|argument| argument.name == name)
256        {
257            Some(index) => {
258                let current = self.arguments.remove(index);
259                self.arguments.insert(index, adjust(current));
260            }
261            None => self
262                .problems
263                .push(format!("`{name}` is not a field of its arguments type")),
264        }
265        self
266    }
267
268    /// Adds an example message and the arguments it gives.
269    #[must_use]
270    pub fn example(mut self, message: impl Into<String>, arguments: serde_json::Value) -> Self {
271        let arguments = match arguments {
272            serde_json::Value::Object(map) => map,
273            serde_json::Value::Null => serde_json::Map::new(),
274            other => {
275                self.problems.push(format!(
276                    "an example's arguments must be an object, not {other}"
277                ));
278                serde_json::Map::new()
279            }
280        };
281        self.examples.push(OperationExample {
282            message: message.into(),
283            arguments,
284            not_given: Vec::new(),
285        });
286        self
287    }
288
289    /// Adds an example message that names or implies `names` without giving them.
290    #[must_use]
291    pub fn example_not_given<'a>(
292        mut self,
293        message: impl Into<String>,
294        names: impl IntoIterator<Item = &'a str>,
295    ) -> Self {
296        self.examples.push(OperationExample {
297            message: message.into(),
298            arguments: serde_json::Map::new(),
299            not_given: names.into_iter().map(str::to_owned).collect(),
300        });
301        self
302    }
303
304    /// Names a read whose result the argument filler is to be shown. Declared only:
305    /// no stage runs reads in 0.1 (`docs/roadmap.md`).
306    #[must_use]
307    pub fn context_read(mut self, read: impl Into<ReadToolKey>) -> Self {
308        self.context_reads.push(read.into());
309        self
310    }
311
312    /// The arguments document an act passes: `null` for an operation declared with no
313    /// arguments type, an object otherwise.
314    #[must_use]
315    pub fn arguments_value(
316        &self,
317        values: impl IntoIterator<Item = (String, serde_json::Value)>,
318    ) -> serde_json::Value {
319        let values: serde_json::Map<String, serde_json::Value> = values.into_iter().collect();
320        let takes_null = self
321            .arguments_schema
322            .as_value()
323            .get("type")
324            .and_then(serde_json::Value::as_str)
325            == Some("null");
326        if takes_null && values.is_empty() {
327            serde_json::Value::Null
328        } else {
329            serde_json::Value::Object(values)
330        }
331    }
332
333    /// Checks an act's arguments against the arguments type.
334    ///
335    /// # Errors
336    ///
337    /// Why they do not fit, as a path and not as the value.
338    pub fn check_arguments(&self, arguments: &serde_json::Value) -> Result<(), String> {
339        if let Some(validator) = &self.validator {
340            return validator(arguments);
341        }
342        crate::schema::validate_against(&self.arguments_schema, arguments)
343            .map_err(|error| error.to_string())
344    }
345
346    /// The argument named `name`.
347    #[must_use]
348    pub fn argument_named(&self, name: &str) -> Option<&ArgumentSpec> {
349        self.arguments.iter().find(|argument| argument.name == name)
350    }
351
352    /// Checks the declaration: every adjusted argument exists, and every example gives
353    /// arguments of the right type and names only real ones.
354    ///
355    /// # Errors
356    ///
357    /// The first problem found, naming the operation.
358    pub fn validate(&self) -> Result<(), OperationSpecError> {
359        let refuse = |reason: String| OperationSpecError {
360            operation: self.key.clone(),
361            reason,
362        };
363        if let Some(problem) = self.problems.first() {
364            return Err(refuse(problem.clone()));
365        }
366        if self.summary.trim().is_empty() {
367            return Err(refuse("has no summary".to_owned()));
368        }
369        for example in &self.examples {
370            for name in example.not_given.iter().chain(example.arguments.keys()) {
371                if self.argument_named(name).is_none() {
372                    return Err(refuse(format!(
373                        "example «{}» names `{name}`, which is not an argument",
374                        example.message
375                    )));
376                }
377            }
378            if example.not_given.is_empty()
379                && let Some(validator) = &self.validator
380            {
381                validator(&serde_json::Value::Object(example.arguments.clone()))
382                    .map_err(|error| refuse(format!("example «{}»: {error}", example.message)))?;
383            }
384        }
385        Ok(())
386    }
387}
388
389/// The operations offered to a turn, by key. A duplicate key is refused, never shadowed.
390#[derive(Debug, Clone, Default, PartialEq)]
391pub struct OperationCatalog {
392    operations: indexmap::IndexMap<OperationKey, OperationSpec>,
393}
394
395impl OperationCatalog {
396    /// A catalog of `operations`.
397    ///
398    /// # Errors
399    ///
400    /// [`crate::error::ReductionError::DuplicateOperation`] for a key offered twice.
401    pub fn new(
402        operations: impl IntoIterator<Item = OperationSpec>,
403    ) -> Result<Self, crate::error::ReductionError> {
404        let mut catalog = Self::default();
405        for spec in operations {
406            catalog.insert(spec)?;
407        }
408        Ok(catalog)
409    }
410
411    /// Adds an operation.
412    ///
413    /// # Errors
414    ///
415    /// [`crate::error::ReductionError::DuplicateOperation`] when its key is taken.
416    pub fn insert(&mut self, spec: OperationSpec) -> Result<(), crate::error::ReductionError> {
417        if self.operations.contains_key(&spec.key) {
418            return Err(crate::error::ReductionError::DuplicateOperation {
419                operation: spec.key,
420            });
421        }
422        self.operations.insert(spec.key.clone(), spec);
423        Ok(())
424    }
425
426    /// The operation with this key.
427    #[must_use]
428    pub fn get(&self, key: &OperationKey) -> Option<&OperationSpec> {
429        self.operations.get(key)
430    }
431
432    /// Every operation, in the order added.
433    pub fn iter(&self) -> impl Iterator<Item = &OperationSpec> {
434        self.operations.values()
435    }
436
437    /// How many there are.
438    #[must_use]
439    pub fn len(&self) -> usize {
440        self.operations.len()
441    }
442
443    /// Whether there are none.
444    #[must_use]
445    pub fn is_empty(&self) -> bool {
446        self.operations.is_empty()
447    }
448}
449
450/// One argument per top-level property, required as the schema says.
451fn derive_arguments(schema: &Schema) -> Vec<ArgumentSpec> {
452    let value = schema.as_value();
453    let Some(properties) = value
454        .get("properties")
455        .and_then(serde_json::Value::as_object)
456    else {
457        return Vec::new();
458    };
459    let required: Vec<&str> = value
460        .get("required")
461        .and_then(serde_json::Value::as_array)
462        .map(|names| names.iter().filter_map(serde_json::Value::as_str).collect())
463        .unwrap_or_default();
464    let defs = value.get("$defs");
465    properties
466        .iter()
467        .map(|(name, property)| {
468            let mut spec = ArgumentSpec::new(name.clone(), shape_of(property, defs));
469            spec.required = required.contains(&name.as_str());
470            spec.description = property
471                .get("description")
472                .and_then(serde_json::Value::as_str)
473                .map(str::to_owned);
474            spec
475        })
476        .collect()
477}
478
479#[cfg(test)]
480mod tests {
481    use serde_json::json;
482
483    use super::*;
484
485    #[derive(Deserialize, JsonSchema)]
486    #[allow(dead_code)]
487    struct SetSubject {
488        /// What the trip is called.
489        value: String,
490    }
491
492    fn set_subject() -> OperationSpec {
493        OperationSpec::new("trip.set_name")
494            .summary("Name the trip.")
495            .arguments::<SetSubject>()
496            .argument("value", |a| a.label("subject").label_in("it-IT", "oggetto"))
497    }
498
499    #[test]
500    fn arguments_come_from_the_type_with_their_documentation() {
501        let spec = set_subject();
502        let value = spec.argument_named("value").unwrap();
503        assert!(value.required);
504        assert_eq!(
505            value.description.as_deref(),
506            Some("What the trip is called.")
507        );
508        assert_eq!(value.shape, ValueShape::Text { written: false });
509        assert!(spec.validate().is_ok());
510    }
511
512    #[test]
513    fn an_example_that_does_not_fit_the_arguments_type_is_refused() {
514        let wrong = set_subject().example("the subject is March", json!({"value": 3}));
515        assert!(wrong.validate().is_err());
516        let unknown = set_subject().example_not_given("set the subject", ["title"]);
517        assert!(unknown.validate().is_err());
518        let fine = set_subject()
519            .example("the subject is March", json!({"value": "March"}))
520            .example_not_given("the subject needs changing", ["value"]);
521        assert!(fine.validate().is_ok());
522    }
523
524    #[test]
525    fn adjusting_an_argument_the_type_does_not_have_is_refused() {
526        let spec = set_subject().argument("title", |a| a.label("title"));
527        assert!(spec.validate().unwrap_err().reason.contains("`title`"));
528    }
529
530    #[test]
531    fn a_summary_is_read_in_the_turns_language_when_it_has_one() {
532        let spec = OperationSpec::new("trip.set_name")
533            .summary("Name the trip.")
534            .summary_in("it-IT", "Dà un nome al viaggio.");
535        assert_eq!(
536            spec.summary_for(&Locale::from("it-IT")),
537            "Dà un nome al viaggio."
538        );
539        assert_eq!(
540            spec.summary_for(&Locale::from("it-CH")),
541            "Dà un nome al viaggio."
542        );
543        assert_eq!(spec.summary_for(&Locale::from("de-DE")), "Name the trip.");
544    }
545}