xapi_rs/data/
verb.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3use crate::{
4    add_language,
5    data::{
6        Canonical, DataError, Fingerprint, LanguageMap, MyLanguageTag, Validate, ValidationError,
7        fingerprint_it,
8    },
9    emit_error,
10};
11use core::fmt;
12use iri_string::types::{IriStr, IriString};
13use serde::{Deserialize, Serialize};
14use serde_with::skip_serializing_none;
15use std::{
16    collections::HashMap,
17    hash::{Hash, Hasher},
18    sync::OnceLock,
19};
20
21/// Enumeration of ADL[^1] [Verb]s [referenced][1] in xAPI.
22///
23/// [1]: https://profiles.adlnet.gov/profile/c752b257-047f-4718-b353-d29238fef2c2/concepts
24/// [^1]: Advanced Distributed Learning (<https://adlnet.gov/>).
25#[derive(Debug, Eq, Hash, PartialEq)]
26pub enum Vocabulary {
27    /// Indicates the actor replied to a question, where the object is
28    /// generally an activity representing the question. The text of the answer
29    /// will often be included in the response inside result.
30    Answered,
31    /// Indicates an inquiry by an actor with the expectation of a response or
32    /// answer to a question.
33    Asked,
34    /// Indicates the actor made an effort to access the object. An attempt
35    /// statement without additional activities could be considered incomplete
36    /// in some cases.
37    Attempted,
38    /// Indicates the actor was present at a virtual or physical event or
39    /// activity.
40    Attended,
41    /// Indicates the actor provided digital or written annotations on or about
42    /// an object.
43    Commented,
44    /// Indicates the actor intentionally departed from the activity or object.
45    Exited,
46    /// Indicates the actor only encountered the object, and is applicable in
47    /// situations where a specific achievement or completion is not required.
48    Experienced,
49    /// Indicates the actor introduced an object into a physical or virtual
50    /// location.
51    Imported,
52    /// Indicates the actor engaged with a physical or virtual object.
53    Interacted,
54    /// Indicates the actor attempted to start an activity.
55    Launched,
56    /// Indicates the highest level of comprehension or competence the actor
57    /// performed in an activity.
58    Mastered,
59    /// Indicates the selected choices, favored options or settings of an actor
60    /// in relation to an object or activity.
61    Preferred,
62    /// Indicates a value of how much of an actor has advanced or moved through
63    /// an activity.
64    Progressed,
65    /// Indicates the actor is officially enrolled or inducted in an activity.
66    Registered,
67    /// Indicates the actor's intent to openly provide access to an object of
68    /// common interest to other actors or groups.
69    Shared,
70    /// A special reserved verb used by a LRS or application to mark a
71    /// statement as invalid. See the xAPI specification for details on Voided
72    /// statements.
73    Voided,
74    /// Indicates the actor gained access to a system or service by identifying
75    /// and authenticating with the credentials provided by the actor.
76    LoggedIn,
77    /// Indicates the actor either lost or discontinued access to a system or
78    /// service.
79    LoggedOut,
80}
81
82/// Return a [Verb] identified by its [Vocabulary] variant.
83pub fn adl_verb(id: Vocabulary) -> &'static Verb {
84    verbs().get(&id).unwrap()
85}
86
87/// Structure consisting of an IRI (Internationalized Resource Identifier) and
88/// a set of labels corresponding to multiple languages or dialects which
89/// provide human-readable meanings of the [Verb].
90///
91/// A [Verb] **always** appears in a [Statement][crate::Statement].
92#[skip_serializing_none]
93#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
94#[serde(deny_unknown_fields)]
95#[serde(rename_all = "camelCase")]
96pub struct Verb {
97    id: IriString,
98    display: Option<LanguageMap>,
99}
100
101#[derive(Debug, Serialize)]
102pub(crate) struct VerbId {
103    id: IriString,
104}
105
106impl From<Verb> for VerbId {
107    fn from(value: Verb) -> Self {
108        VerbId { id: value.id }
109    }
110}
111
112impl From<VerbId> for Verb {
113    fn from(value: VerbId) -> Self {
114        Verb {
115            id: value.id,
116            display: None,
117        }
118    }
119}
120
121impl Verb {
122    fn from(id: &str) -> Result<Self, DataError> {
123        let iri = IriStr::new(id)?;
124        Ok(Verb {
125            id: iri.into(),
126            display: None,
127        })
128    }
129
130    /// Return a [Verb] _Builder_.
131    pub fn builder() -> VerbBuilder<'static> {
132        VerbBuilder::default()
133    }
134
135    /// Return the `id` field.
136    pub fn id(&self) -> &IriStr {
137        &self.id
138    }
139
140    /// Return the `id` field as a string.
141    pub fn id_as_str(&self) -> &str {
142        self.id.as_str()
143    }
144
145    /// Return TRUE if this is the _voided_ special verb; FALSE otherwise.
146    pub fn is_voided(&self) -> bool {
147        self.id.eq(adl_verb(Vocabulary::Voided).id())
148    }
149
150    /// Return the human readable representation of the Verb in the specified
151    /// language `tag`. These labels do not have any impact on the meaning of
152    /// a [Statement][crate::Statement] where a [Verb] is used, but serve to
153    /// give human-readable display of that meaning in different languages.
154    pub fn display(&self, tag: &MyLanguageTag) -> Option<&str> {
155        match &self.display {
156            Some(lm) => lm.get(tag),
157            None => None,
158        }
159    }
160
161    /// Return a reference to the [`display`][LanguageMap] if this instance has
162    /// one; `None` otherwise.
163    pub fn display_as_map(&self) -> Option<&LanguageMap> {
164        self.display.as_ref()
165    }
166
167    /// Return the fingerprint of this instance.
168    pub fn uid(&self) -> u64 {
169        fingerprint_it(self)
170    }
171
172    /// Return TRUE if this is _Equivalent_ to `that`; FALSE otherwise.
173    pub fn equivalent(&self, that: &Verb) -> bool {
174        self.uid() == that.uid()
175    }
176
177    /// Extend this instance's `display` language-map from bindings present
178    /// in `other`. Entries present in `other` but not in `self` are added
179    /// to the latter, while values in `other` with same keys will replace
180    /// current values in `self`.
181    ///
182    /// Return TRUE if this instance was modified, FALSE otherwise.
183    pub fn extend(&mut self, other: Verb) -> bool {
184        match (&self.display, other.display) {
185            (_, None) => false,
186            (None, Some(y)) => {
187                self.display = Some(y);
188                true
189            }
190            (Some(x), Some(y)) => {
191                let mut old_display = x.to_owned();
192                old_display.extend(y);
193                self.display = Some(old_display);
194                true
195            }
196        }
197    }
198}
199
200impl fmt::Display for Verb {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        let mut vec = vec![];
203
204        vec.push(format!("id: \"{}\"", self.id));
205        if self.display.is_some() {
206            vec.push(format!("display: {}", self.display.as_ref().unwrap()));
207        }
208
209        let res = vec
210            .iter()
211            .map(|x| x.to_string())
212            .collect::<Vec<_>>()
213            .join(", ");
214        write!(f, "Verb{{ {res} }}")
215    }
216}
217
218impl Fingerprint for Verb {
219    fn fingerprint<H: Hasher>(&self, state: &mut H) {
220        let (x, y) = self.id.as_slice().to_absolute_and_fragment();
221        x.normalize().to_string().hash(state);
222        y.hash(state);
223        // exclude `display`
224    }
225}
226
227impl Validate for Verb {
228    fn validate(&self) -> Vec<ValidationError> {
229        let mut vec = vec![];
230
231        if self.id.is_empty() {
232            vec.push(ValidationError::Empty("id".into()))
233        }
234
235        vec
236    }
237}
238
239impl Canonical for Verb {
240    fn canonicalize(&mut self, tags: &[MyLanguageTag]) {
241        if self.display.is_some() {
242            self.display.as_mut().unwrap().canonicalize(tags);
243        }
244    }
245}
246
247/// A _Builder_ that knows how to construct a [Verb].
248#[derive(Debug, Default)]
249pub struct VerbBuilder<'a> {
250    _id: Option<&'a IriStr>,
251    _display: Option<LanguageMap>,
252}
253
254impl<'a> VerbBuilder<'a> {
255    /// Set the identifier of this instance.
256    ///
257    /// Raise a [DataError] if the input string is empty or is not a valid
258    /// IRI string.
259    pub fn id(mut self, val: &'a str) -> Result<Self, DataError> {
260        let id = val.trim();
261        if id.is_empty() {
262            emit_error!(DataError::Validation(ValidationError::Empty("id".into())))
263        } else {
264            let iri = IriStr::new(id)?;
265            // do we already have it in our vocabulary?
266            if let Some(v) = is_adl_verb(iri) {
267                self._id = Some(&v.id)
268            } else {
269                self._id = Some(iri);
270            }
271            Ok(self)
272        }
273    }
274
275    /// Add the given `label` to the display dictionary keyed by the given `tag`.
276    ///
277    /// Raise a [DataError] if the tag is not a valid Language Tag.
278    pub fn display(mut self, tag: &MyLanguageTag, label: &str) -> Result<Self, DataError> {
279        add_language!(self._display, tag, label);
280        Ok(self)
281    }
282
283    /// Set (as in replace) the `display` property for the instance being built
284    /// w/ the one passed as argument.
285    pub fn with_display(mut self, map: LanguageMap) -> Result<Self, DataError> {
286        self._display = Some(map);
287        Ok(self)
288    }
289
290    /// Create a [Verb] instance.
291    ///
292    /// Raise [DataError] if the definition (`id`) field is not set or is an
293    /// invalid IRI.
294    pub fn build(self) -> Result<Verb, DataError> {
295        if self._id.is_none() {
296            emit_error!(DataError::Validation(ValidationError::MissingField(
297                "id".into()
298            )))
299        } else {
300            let iri = self._id.unwrap();
301            if iri.is_empty() {
302                emit_error!(DataError::Validation(ValidationError::Empty("id".into())))
303            } else {
304                Ok(Verb {
305                    id: self._id.unwrap().into(),
306                    display: self._display,
307                })
308            }
309        }
310    }
311}
312
313static VERBS: OnceLock<HashMap<Vocabulary, Verb>> = OnceLock::new();
314fn verbs() -> &'static HashMap<Vocabulary, Verb> {
315    VERBS.get_or_init(|| {
316        HashMap::from([
317            (
318                Vocabulary::Answered,
319                Verb::from("http://adlnet.gov/expapi/verbs/answered").unwrap(),
320            ),
321            (
322                Vocabulary::Asked,
323                Verb::from("http://adlnet.gov/expapi/verbs/asked").unwrap(),
324            ),
325            (
326                Vocabulary::Attempted,
327                Verb::from("http://adlnet.gov/expapi/verbs/attempted").unwrap(),
328            ),
329            (
330                Vocabulary::Attended,
331                Verb::from("http://adlnet.gov/expapi/verbs/attended").unwrap(),
332            ),
333            (
334                Vocabulary::Commented,
335                Verb::from("http://adlnet.gov/expapi/verbs/commented").unwrap(),
336            ),
337            (
338                Vocabulary::Exited,
339                Verb::from("http://adlnet.gov/expapi/verbs/exited").unwrap(),
340            ),
341            (
342                Vocabulary::Experienced,
343                Verb::from("http://adlnet.gov/expapi/verbs/experienced").unwrap(),
344            ),
345            (
346                Vocabulary::Imported,
347                Verb::from("http://adlnet.gov/expapi/verbs/imported").unwrap(),
348            ),
349            (
350                Vocabulary::Interacted,
351                Verb::from("http://adlnet.gov/expapi/verbs/interacted").unwrap(),
352            ),
353            (
354                Vocabulary::Launched,
355                Verb::from("http://adlnet.gov/expapi/verbs/launched").unwrap(),
356            ),
357            (
358                Vocabulary::Mastered,
359                Verb::from("http://adlnet.gov/expapi/verbs/mastered").unwrap(),
360            ),
361            (
362                Vocabulary::Preferred,
363                Verb::from("http://adlnet.gov/expapi/verbs/preferred").unwrap(),
364            ),
365            (
366                Vocabulary::Progressed,
367                Verb::from("http://adlnet.gov/expapi/verbs/progressed").unwrap(),
368            ),
369            (
370                Vocabulary::Registered,
371                Verb::from("http://adlnet.gov/expapi/verbs/registered").unwrap(),
372            ),
373            (
374                Vocabulary::Shared,
375                Verb::from("http://adlnet.gov/expapi/verbs/shared").unwrap(),
376            ),
377            (
378                Vocabulary::Voided,
379                Verb::from("http://adlnet.gov/expapi/verbs/voided").unwrap(),
380            ),
381            (
382                Vocabulary::LoggedIn,
383                Verb::from("http://adlnet.gov/expapi/verbs/logged-in").unwrap(),
384            ),
385            (
386                Vocabulary::LoggedOut,
387                Verb::from("http://adlnet.gov/expapi/verbs/logged-out").unwrap(),
388            ),
389        ])
390    })
391}
392
393fn is_adl_verb(iri: &IriStr) -> Option<&Verb> {
394    if let Some(verb) = verbs().values().find(|&x| x.id() == iri) {
395        Some(verb)
396    } else {
397        None
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use iri_string::{format::ToDedicatedString, spec::IriSpec, validate::iri};
405    use std::str::FromStr;
406    use tracing_test::traced_test;
407    use url::Url;
408
409    const JSON: &str =
410        r#"{"id": "http://adlnet.gov/expapi/verbs/logged-out","display": {"en": "logged-out"}}"#;
411
412    #[traced_test]
413    #[test]
414    fn test_serde() {
415        // serializing/deserializing is symmetrical
416        let v1 = adl_verb(Vocabulary::LoggedIn);
417        let json_result = serde_json::to_string(v1);
418        assert!(json_result.is_ok());
419        let json = json_result.unwrap();
420        let v1_result = serde_json::from_str::<Verb>(&json);
421        assert!(v1_result.is_ok());
422        let v11 = v1_result.unwrap();
423        assert_eq!(v11.id.as_str(), "http://adlnet.gov/expapi/verbs/logged-in");
424
425        let v2 = Verb::from("ftp://example.net/whatever").unwrap();
426        let json_result = serde_json::to_string(&v2);
427        assert!(json_result.is_ok());
428        let json = json_result.unwrap();
429        // language map is NOT serialized if/when empty
430        assert!(!json.contains("display"));
431    }
432
433    #[test]
434    fn test_deserialization() -> Result<(), DataError> {
435        let de_result = serde_json::from_str::<Verb>(JSON);
436        assert!(de_result.is_ok());
437        let v = de_result.unwrap();
438
439        let url = Url::parse("http://adlnet.gov/expapi/verbs/logged-out").unwrap();
440        assert_eq!(url.as_str(), v.id());
441        assert!(v.display.is_some());
442        let en_result = v.display(&MyLanguageTag::from_str("en")?);
443        assert!(en_result.is_some());
444        assert_eq!(en_result.unwrap(), "logged-out");
445
446        Ok(())
447    }
448
449    #[test]
450    fn test_display() {
451        const DISPLAY: &str = r#"Verb{ id: "http://adlnet.gov/expapi/verbs/logged-out", display: {"en":"logged-out"} }"#;
452
453        let de_result = serde_json::from_str::<Verb>(JSON);
454        let v = de_result.unwrap();
455        let display = format!("{}", v);
456        assert_eq!(display, DISPLAY);
457    }
458
459    #[test]
460    fn test_eq() {
461        let de_result = serde_json::from_str::<Verb>(JSON);
462        let v1 = de_result.unwrap();
463
464        // equality testing of a verb w/ a populated `display` property (the
465        // JSON deserialized instance) and one w/ the same `id` but w/o a
466        // populated `display` should fail
467        assert_ne!(&v1, adl_verb(Vocabulary::LoggedOut));
468        // however an equivalence test between the same should succeed.
469        assert!(v1.equivalent(adl_verb(Vocabulary::LoggedOut)));
470
471        // between instances w/ different `id` values both should fail
472        assert_ne!(&v1, adl_verb(Vocabulary::LoggedIn));
473        assert!(!v1.equivalent(adl_verb(Vocabulary::LoggedIn)));
474
475        let v3 = Verb::from("http://adlnet.gov/expapi/verbs/logged-out").unwrap();
476        // ensure equality fails when Verb has no `display`...
477        assert_ne!(v1, v3);
478        // ...but equivalence succeeds...
479        assert!(v1.equivalent(&v3));
480    }
481
482    #[traced_test]
483    #[test]
484    fn test_normalized() {
485        let iri = IriStr::new("HTTP://example.COM/foo/./bar/%2e%2e/../baz?query#fragment").unwrap();
486        let normalized = iri.normalize().to_dedicated_string();
487        assert_eq!(normalized, "http://example.com/baz?query#fragment");
488
489        let iri = IriStr::new("HTTP://Résumé.example.ORG").unwrap();
490        let normalized = iri.normalize().to_dedicated_string();
491        // NOTE (rsn) 20240416 - turns out that normalized IRLs keep their
492        // domain names in upper-case if they are not all ascii to start w/ :(
493        assert_eq!(normalized, "http://Résumé.example.ORG");
494    }
495
496    #[traced_test]
497    #[test]
498    fn test_validation() {
499        const IRI1_STR: &str = "HTTP://Résumé.example.ORG";
500        const IRI2_STR: &str = "http://résumé.example.org";
501
502        let v1 = Verb::from(IRI1_STR).unwrap();
503        let r1 = v1.validate();
504        assert!(r1.is_empty());
505
506        let v2 = Verb::from(IRI2_STR).unwrap();
507        let r2 = v2.validate();
508        assert!(r2.is_empty());
509
510        assert_ne!(v1, v2);
511
512        // both however should pass ri_string::validate::iri...
513        assert!(iri::<IriSpec>(IRI1_STR).is_ok());
514        assert!(iri::<IriSpec>(IRI2_STR).is_ok());
515    }
516}