xapi_rs/data/
activity_definition.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3use crate::{
4    MyLanguageTag, add_language,
5    data::{
6        Canonical, DataError, Extensions, InteractionComponent, InteractionType, LanguageMap,
7        Validate, ValidationError, validate::validate_irl,
8    },
9    emit_error, merge_maps,
10};
11use core::fmt;
12use iri_string::types::{IriStr, IriString};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use serde_with::skip_serializing_none;
16
17/// Structure that provides additional information (metadata) related to an
18/// [Activity][crate::Activity].
19#[skip_serializing_none]
20#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
21#[serde(rename_all = "camelCase")]
22pub struct ActivityDefinition {
23    name: Option<LanguageMap>,
24    description: Option<LanguageMap>,
25    #[serde(rename = "type")]
26    type_: Option<IriString>,
27    more_info: Option<IriString>,
28    /// IMPORTANT (20240925) - 'interactionType' property must be present if any
29    /// of the 'correctResponsesPattern', 'choices', 'scale', 'source', 'target',
30    /// or 'steps' are.
31    interaction_type: Option<InteractionType>,
32    correct_responses_pattern: Option<Vec<String>>,
33    choices: Option<Vec<InteractionComponent>>,
34    scale: Option<Vec<InteractionComponent>>,
35    source: Option<Vec<InteractionComponent>>,
36    target: Option<Vec<InteractionComponent>>,
37    steps: Option<Vec<InteractionComponent>>,
38    extensions: Option<Extensions>,
39}
40
41impl ActivityDefinition {
42    /// Return an [ActivityDefinition] _Builder_.
43    pub fn builder() -> ActivityDefinitionBuilder<'static> {
44        ActivityDefinitionBuilder::default()
45    }
46
47    /// Return the `name` for the given language `tag` if it exists; `None`
48    /// otherwise.
49    pub fn name(&self, tag: &MyLanguageTag) -> Option<&str> {
50        match &self.name {
51            Some(lm) => lm.get(tag),
52            None => None,
53        }
54    }
55
56    /// Return the `description` for the given language `tag` if it exists;
57    /// `None` otherwise.
58    pub fn description(&self, tag: &MyLanguageTag) -> Option<&str> {
59        match &self.description {
60            Some(lm) => lm.get(tag),
61            None => None,
62        }
63    }
64
65    /// Return the `type_` field if set; `None` otherwise.
66    pub fn type_(&self) -> Option<&IriStr> {
67        self.type_.as_deref()
68    }
69
70    /// Return the `more_info` field if set; `None` otherwise.
71    ///
72    /// When set, it's an IRL that points to information about the associated
73    /// [Activity][crate::Activity] possibly incl. a way to launch it.
74    pub fn more_info(&self) -> Option<&IriStr> {
75        self.more_info.as_deref()
76    }
77
78    /// Return the `interaction_type` field if set; `None` otherwise.
79    ///
80    /// Possible values are: [`true-false`][InteractionType#variant.TrueFalse],
81    /// [`choice`][InteractionType#variant.Choice],
82    /// [`fill-in`][InteractionType#variant.FillIn],
83    /// [`long-fill-in`][InteractionType#variant.LongFillIn],
84    /// [`matching`][InteractionType#variant.Matching],
85    /// [`performance`][InteractionType#variant.Performance],
86    /// [`sequencing`][InteractionType#variant.Sequencing],
87    /// [`likert`][InteractionType#variant.Likert],
88    /// [`numeric`][InteractionType#variant.Numeric], and
89    /// [`other`][InteractionType#variant.Other],
90    pub fn interaction_type(&self) -> Option<&InteractionType> {
91        self.interaction_type.as_ref()
92    }
93
94    /// Return the `correct_responses_pattern` field if set; `None` otherwise.
95    ///
96    /// When set, it's a Vector of patterns representing the correct response
97    /// to the interaction.
98    ///
99    /// The structure of the patterns vary depending on the `interaction_type`.
100    pub fn correct_responses_pattern(&self) -> Option<&Vec<String>> {
101        self.correct_responses_pattern.as_ref()
102    }
103
104    /// Return the `choices` field if set; `None` otherwise.
105    ///
106    /// When set, it's a vector of of [InteractionComponent]s representing the
107    /// correct response to the interaction.
108    ///
109    /// The contents of item(s) in the vector are specific to the given
110    /// `interaction_type`.
111    pub fn choices(&self) -> Option<&Vec<InteractionComponent>> {
112        self.choices.as_ref()
113    }
114
115    /// Return the `scale` field if set; `None` otherwise.
116    ///
117    /// When set, it's a vector of of [InteractionComponent]s representing the
118    /// correct response to the interaction.
119    ///
120    /// The contents of item(s) in the vector are specific to the given
121    /// `interaction_type`.
122    pub fn scale(&self) -> Option<&Vec<InteractionComponent>> {
123        self.scale.as_ref()
124    }
125
126    /// Return the `source` field if set; `None` otherwise.
127    ///
128    /// When set, it's a vector of of [InteractionComponent]s representing the
129    /// correct response to the interaction.
130    ///
131    /// The contents of item(s) in the vector are specific to the given
132    /// `interaction_type`.
133    pub fn source(&self) -> Option<&Vec<InteractionComponent>> {
134        self.source.as_ref()
135    }
136
137    /// Return the `target` field if set; `None` otherwise.
138    ///
139    /// When set, it's a vector of of [InteractionComponent]s representing the
140    /// correct response to the interaction.
141    ///
142    /// The contents of item(s) in the vector are specific to the given
143    /// `interaction_type`.
144    pub fn target(&self) -> Option<&Vec<InteractionComponent>> {
145        self.target.as_ref()
146    }
147
148    /// Return the `steps` field if set; `None` otherwise.
149    ///
150    /// When set, it's a vector of of [InteractionComponent]s representing the
151    /// correct response to the interaction.
152    ///
153    /// The contents of item(s) in the vector are specific to the given
154    /// `interaction_type`.
155    pub fn steps(&self) -> Option<&Vec<InteractionComponent>> {
156        self.steps.as_ref()
157    }
158
159    /// Return the [`extensions`][Extensions] field if set; `None` otherwise.
160    pub fn extensions(&self) -> Option<&Extensions> {
161        self.extensions.as_ref()
162    }
163
164    /// Return the _extension_ keyed by `key` if it exists; `None` otherwise.
165    pub fn extension(&self, key: &IriStr) -> Option<&Value> {
166        if self.extensions.is_none() {
167            None
168        } else {
169            self.extensions.as_ref().unwrap().get(key)
170        }
171    }
172
173    /// Consume `other` merging it into `this`.
174    pub fn merge(&mut self, that: Self) {
175        // merge two Option<Vec<InteractionComponents>>...
176        fn merge_opt_collections(
177            dst: &mut Option<Vec<InteractionComponent>>,
178            src: Option<Vec<InteractionComponent>>,
179        ) {
180            match dst {
181                Some(lhs) => {
182                    if let Some(rhs) = src {
183                        InteractionComponent::merge_collections(lhs, rhs)
184                    }
185                }
186                None => *dst = src,
187            }
188        }
189
190        // extend b-tree maps...
191        merge_maps!(&mut self.name, that.name);
192        merge_maps!(&mut self.description, that.description);
193        merge_maps!(&mut self.extensions, that.extensions);
194        // overwrite if none...
195        if self.type_.is_none() {
196            self.type_ = that.type_
197        }
198        if self.more_info.is_none() {
199            self.more_info = that.more_info
200        }
201        if self.interaction_type.is_none() {
202            self.interaction_type = that.interaction_type
203        }
204        // combine string collections...
205        match &mut self.correct_responses_pattern {
206            Some(this_field) => {
207                if let Some(that_field) = that.correct_responses_pattern {
208                    this_field.extend(that_field);
209                    // NOTE (rsn) 20250412 - ensure no dups...
210                    this_field.sort();
211                    this_field.dedup();
212                }
213            }
214            None => self.correct_responses_pattern = that.correct_responses_pattern,
215        }
216        // merge optional collections of InteractionComponents...
217        merge_opt_collections(&mut self.choices, that.choices);
218        merge_opt_collections(&mut self.scale, that.scale);
219        merge_opt_collections(&mut self.source, that.source);
220        merge_opt_collections(&mut self.target, that.target);
221        merge_opt_collections(&mut self.steps, that.steps);
222    }
223}
224
225impl fmt::Display for ActivityDefinition {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        let mut vec = vec![];
228        if self.name.is_some() {
229            vec.push(format!("name: {}", self.name.as_ref().unwrap()));
230        }
231        if self.description.is_some() {
232            vec.push(format!(
233                "description: {}",
234                self.description.as_ref().unwrap()
235            ));
236        }
237        if self.type_.is_some() {
238            vec.push(format!("type: \"{}\"", self.type_.as_ref().unwrap()));
239        }
240        if self.more_info.is_some() {
241            vec.push(format!(
242                "moreInfo: \"{}\"",
243                self.more_info.as_ref().unwrap()
244            ));
245        }
246        if self.interaction_type.is_some() {
247            vec.push(format!(
248                "interactionType: {}",
249                self.interaction_type.as_ref().unwrap()
250            ));
251        }
252        if self.correct_responses_pattern.is_some() {
253            vec.push(format!(
254                "correctResponsesPattern: {}",
255                array_to_display_str(self.correct_responses_pattern.as_ref().unwrap())
256            ));
257        }
258        if self.choices.is_some() {
259            vec.push(format!(
260                "choices: {}",
261                vec_to_display_str(self.choices.as_ref().unwrap())
262            ));
263        }
264        if self.scale.is_some() {
265            vec.push(format!(
266                "scale: {}",
267                vec_to_display_str(self.scale.as_ref().unwrap())
268            ));
269        }
270        if self.source.is_some() {
271            vec.push(format!(
272                "source: {}",
273                vec_to_display_str(self.source.as_ref().unwrap())
274            ));
275        }
276        if self.target.is_some() {
277            vec.push(format!(
278                "target: {}",
279                vec_to_display_str(self.target.as_ref().unwrap())
280            ));
281        }
282        if self.steps.is_some() {
283            vec.push(format!(
284                "steps: {}",
285                vec_to_display_str(self.steps.as_ref().unwrap())
286            ));
287        }
288        if self.extensions.is_some() {
289            vec.push(format!("extensions: {}", self.extensions.as_ref().unwrap()))
290        }
291        let res = vec
292            .iter()
293            .map(|x| x.to_string())
294            .collect::<Vec<_>>()
295            .join(", ");
296        write!(f, "ActivityDefinition{{ {res} }}")
297    }
298}
299
300impl Validate for ActivityDefinition {
301    fn validate(&self) -> Vec<ValidationError> {
302        let mut vec: Vec<ValidationError> = vec![];
303
304        // validate type
305        if self.type_.is_some() && self.type_.as_ref().unwrap().is_empty() {
306            vec.push(ValidationError::InvalidIRI("type".into()))
307        }
308        // validate more_info
309        if self.more_info.is_some() {
310            validate_irl(self.more_info.as_ref().unwrap()).unwrap_or_else(|x| vec.push(x));
311        }
312        // interaction type is guaranteed to be valid when present; is it missing?
313        if (self.correct_responses_pattern.is_some()
314            || self.choices.is_some()
315            || self.scale.is_some()
316            || self.source.is_some()
317            || self.target.is_some()
318            || self.steps.is_some())
319            && self.interaction_type.is_none()
320        {
321            vec.push(ValidationError::ConstraintViolation(
322                "Activity definition interaction-type must be present when any Interaction Activities properties is too".into(),
323            ))
324        }
325        // validate correct response pattern
326        if self.correct_responses_pattern.is_some() {
327            for it in self.correct_responses_pattern.as_ref().unwrap().iter() {
328                if it.is_empty() {
329                    vec.push(ValidationError::Empty("correctResponsePattern".into()))
330                }
331            }
332        }
333        // validate choices
334        if self.choices.is_some() {
335            self.choices
336                .as_ref()
337                .unwrap()
338                .iter()
339                .for_each(|x| vec.extend(x.validate()));
340        }
341        // validate scale
342        if self.scale.is_some() {
343            self.scale
344                .as_ref()
345                .unwrap()
346                .iter()
347                .for_each(|x| vec.extend(x.validate()));
348        }
349        // validate source
350        if self.source.is_some() {
351            self.source
352                .as_ref()
353                .unwrap()
354                .iter()
355                .for_each(|x| vec.extend(x.validate()));
356        }
357        // validate target
358        if self.target.is_some() {
359            self.target
360                .as_ref()
361                .unwrap()
362                .iter()
363                .for_each(|x| vec.extend(x.validate()));
364        }
365        // validate steps
366        if self.steps.is_some() {
367            self.steps
368                .as_ref()
369                .unwrap()
370                .iter()
371                .for_each(|x| vec.extend(x.validate()));
372        }
373
374        vec
375    }
376}
377
378impl Canonical for ActivityDefinition {
379    fn canonicalize(&mut self, language_tags: &[MyLanguageTag]) {
380        if self.name.is_some() {
381            self.name.as_mut().unwrap().canonicalize(language_tags)
382        }
383        if self.description.is_some() {
384            self.description
385                .as_mut()
386                .unwrap()
387                .canonicalize(language_tags)
388        }
389        if self.choices.is_some() {
390            for it in self.choices.as_mut().unwrap() {
391                it.canonicalize(language_tags)
392            }
393        }
394        if self.scale.is_some() {
395            for it in self.scale.as_mut().unwrap() {
396                it.canonicalize(language_tags)
397            }
398        }
399        if self.source.is_some() {
400            for it in self.source.as_mut().unwrap() {
401                it.canonicalize(language_tags)
402            }
403        }
404        if self.target.is_some() {
405            for it in self.target.as_mut().unwrap() {
406                it.canonicalize(language_tags)
407            }
408        }
409        if self.steps.is_some() {
410            for it in self.steps.as_mut().unwrap() {
411                it.canonicalize(language_tags)
412            }
413        }
414    }
415}
416
417/// A Type that knows how to construct an [ActivityDefinition]
418#[derive(Debug, Default)]
419pub struct ActivityDefinitionBuilder<'a> {
420    _name: Option<LanguageMap>,
421    _description: Option<LanguageMap>,
422    _type_: Option<&'a IriStr>,
423    _more_info: Option<&'a IriStr>,
424    _interaction_type: Option<InteractionType>,
425    _correct_responses_pattern: Option<Vec<String>>,
426    _choices: Option<Vec<InteractionComponent>>,
427    _scale: Option<Vec<InteractionComponent>>,
428    _source: Option<Vec<InteractionComponent>>,
429    _target: Option<Vec<InteractionComponent>>,
430    _steps: Option<Vec<InteractionComponent>>,
431    _extensions: Option<Extensions>,
432}
433
434impl<'a> ActivityDefinitionBuilder<'a> {
435    /// Add the given `label` to the `name` dictionary keyed by the given `tag`.
436    ///
437    /// Raise [DataError] if `tag` is not a valid Language Tag.
438    pub fn name(mut self, tag: &MyLanguageTag, label: &str) -> Result<Self, DataError> {
439        add_language!(self._name, tag, label);
440        Ok(self)
441    }
442
443    /// Add the given `label` to the `description` dictionary keyed by the given
444    /// `tag`.
445    ///
446    /// Raise [DataError] if `tag` is not a valid Language Tag.
447    pub fn description(mut self, tag: &MyLanguageTag, label: &str) -> Result<Self, DataError> {
448        add_language!(self._description, tag, label);
449        Ok(self)
450    }
451
452    /// Set the `type_` field.
453    pub fn type_(mut self, val: &'a str) -> Result<Self, DataError> {
454        let val = val.trim();
455        if val.is_empty() {
456            emit_error!(DataError::Validation(ValidationError::Empty("type".into())))
457        } else {
458            let iri = IriStr::new(val)?;
459            self._type_ = Some(iri);
460            Ok(self)
461        }
462    }
463
464    /// Set the `more_info` field.
465    pub fn more_info(mut self, val: &'a str) -> Result<Self, DataError> {
466        let val = val.trim();
467        if val.is_empty() {
468            emit_error!(DataError::Validation(ValidationError::Empty(
469                "more_info".into()
470            )))
471        } else {
472            let val = IriStr::new(val)?;
473            validate_irl(val)?;
474            self._more_info = Some(val);
475            Ok(self)
476        }
477    }
478
479    /// Set the `interaction_type` field.
480    pub fn interaction_type(mut self, val: InteractionType) -> Self {
481        self._interaction_type = Some(val);
482        self
483    }
484
485    /// Add `val` to correct responses pattern.
486    pub fn correct_responses_pattern(mut self, val: &str) -> Result<Self, DataError> {
487        let val = val.trim();
488        if val.is_empty() {
489            emit_error!(DataError::Validation(ValidationError::Empty(
490                "correct_responses_pattern".into()
491            )))
492        }
493        if self._correct_responses_pattern.is_none() {
494            self._correct_responses_pattern = Some(vec![])
495        }
496        self._correct_responses_pattern
497            .as_mut()
498            .unwrap()
499            .push(val.to_string());
500        Ok(self)
501    }
502
503    /// Add `val` to `choices`.
504    pub fn choices(mut self, val: InteractionComponent) -> Result<Self, DataError> {
505        val.check_validity()?;
506        if self._choices.is_none() {
507            self._choices = Some(vec![])
508        }
509        self._choices.as_mut().unwrap().push(val);
510        Ok(self)
511    }
512
513    /// Add `val` to `scale`.
514    pub fn scale(mut self, val: InteractionComponent) -> Result<Self, DataError> {
515        val.check_validity()?;
516        if self._scale.is_none() {
517            self._scale = Some(vec![])
518        }
519        self._scale.as_mut().unwrap().push(val);
520        Ok(self)
521    }
522
523    /// Add `val` to `source`.
524    pub fn source(mut self, val: InteractionComponent) -> Result<Self, DataError> {
525        val.check_validity()?;
526        if self._source.is_none() {
527            self._source = Some(vec![])
528        }
529        self._source.as_mut().unwrap().push(val);
530        Ok(self)
531    }
532
533    /// Add `val` to `target`.
534    pub fn target(mut self, val: InteractionComponent) -> Result<Self, DataError> {
535        val.check_validity()?;
536        if self._target.is_none() {
537            self._target = Some(vec![])
538        }
539        self._target.as_mut().unwrap().push(val);
540        Ok(self)
541    }
542
543    /// Add `val` to `steps`.
544    pub fn steps(mut self, val: InteractionComponent) -> Result<Self, DataError> {
545        val.check_validity()?;
546        if self._steps.is_none() {
547            self._steps = Some(vec![])
548        }
549        self._steps.as_mut().unwrap().push(val);
550        Ok(self)
551    }
552
553    /// Add an extension's `key` and `value` pair.
554    pub fn extension(mut self, key: &str, value: &Value) -> Result<Self, DataError> {
555        if self._extensions.is_none() {
556            self._extensions = Some(Extensions::new());
557        }
558        let _ = self._extensions.as_mut().unwrap().add(key, value);
559        Ok(self)
560    }
561
562    /// Create an [ActivityDefinition] from set field values.
563    ///
564    /// Raise [DataError] if no field was set.
565    pub fn build(self) -> Result<ActivityDefinition, DataError> {
566        if self._name.is_none()
567            && self._description.is_none()
568            && self._type_.is_none()
569            && self._more_info.is_none()
570            && self._interaction_type.is_none()
571            && self._correct_responses_pattern.is_none()
572            && self._choices.is_none()
573            && self._scale.is_none()
574            && self._source.is_none()
575            && self._target.is_none()
576            && self._steps.is_none()
577            && self._extensions.is_none()
578        {
579            emit_error!(DataError::Validation(ValidationError::ConstraintViolation(
580                "At least 1 field must be set".into()
581            )))
582        }
583
584        if self._interaction_type.is_none()
585            && (self._correct_responses_pattern.is_some()
586                || self._choices.is_some()
587                || self._scale.is_some()
588                || self._source.is_some()
589                || self._target.is_some()
590                || self._steps.is_some())
591        {
592            emit_error!(DataError::Validation(ValidationError::MissingField(
593                "interaction_type".into()
594            )))
595        }
596
597        Ok(ActivityDefinition {
598            name: self._name,
599            description: self._description,
600            type_: if self._type_.is_none() {
601                None
602            } else {
603                Some(self._type_.unwrap().into())
604            },
605            more_info: if self._more_info.is_none() {
606                None
607            } else {
608                Some(self._more_info.unwrap().into())
609            },
610            interaction_type: self._interaction_type,
611            correct_responses_pattern: self._correct_responses_pattern,
612            choices: self._choices,
613            scale: self._scale,
614            source: self._source,
615            target: self._target,
616            steps: self._steps,
617
618            extensions: self._extensions,
619        })
620    }
621}
622
623fn array_to_display_str(val: &[String]) -> String {
624    let mut vec = vec![];
625    for v in val.iter() {
626        vec.push(format!("\"{v}\""))
627    }
628    vec.iter()
629        .map(|x| x.to_string())
630        .collect::<Vec<_>>()
631        .join(", ")
632}
633
634fn vec_to_display_str(val: &Vec<InteractionComponent>) -> String {
635    let mut vec = vec![];
636    for ic in val {
637        vec.push(format!("{ic}"))
638    }
639    vec.iter()
640        .map(|x| x.to_string())
641        .collect::<Vec<_>>()
642        .join(", ")
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648    use tracing_test::traced_test;
649
650    #[traced_test]
651    #[test]
652    fn test_display() {
653        const DISPLAY: &str = r#"ActivityDefinition{ description: {"en-US":"Does the xAPI include the concept of statements?"}, type: "http://adlnet.gov/expapi/activities/cmi.interaction", interactionType: true-false, correctResponsesPattern: "true" }"#;
654
655        let json = r#"{
656            "description": {
657                "en-US": "Does the xAPI include the concept of statements?"
658            },
659            "type": "http://adlnet.gov/expapi/activities/cmi.interaction",
660            "interactionType": "true-false",
661            "correctResponsesPattern": [
662                "true"
663            ]
664        }"#;
665
666        let de_result = serde_json::from_str::<ActivityDefinition>(json);
667        assert!(de_result.is_ok());
668        let ad = de_result.unwrap();
669        let display = format!("{}", ad);
670        assert_eq!(display, DISPLAY);
671    }
672
673    #[traced_test]
674    #[test]
675    fn test_missing_interaction_type() {
676        const BAD: &str = r#"{
677"name":{"en": "Fill-In"},
678"description":{"en": "Ben is often heard saying:"},
679"type":"http://adlnet.gov/expapi/activities/cmi.interaction",
680"moreInfo":"http://virtualmeeting.example.com/345256",
681"correctResponsesPattern":["Bob's your uncle"],
682"extensions":{
683 "http://example.com/profiles/meetings/extension/location":"X:\\\\meetings\\\\minutes\\\\examplemeeting.one",
684 "http://example.com/profiles/meetings/extension/reporter":{"name":"Thomas","id":"http://openid.com/342"}
685}}"#;
686
687        let de_result = serde_json::from_str::<ActivityDefinition>(BAD);
688        assert!(de_result.is_ok());
689        let ad = de_result.unwrap();
690        // should not be valid b/c missing interaction_type!
691        assert!(!ad.is_valid());
692    }
693}