Skip to main content

xapi_data/
verb.rs

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