Skip to main content

onetaskgraph_plugin_api/
status_mapping.rs

1//! `status_mapping`: the one grammar every source that names its statuses is configured with.
2//!
3//! A source whose backend has a vocabulary of status names of its own — a board's `Status`
4//! options, a Linear team's workflow states and its workspace's project statuses — is told
5//! which name each [`StatusCategory`] is, for each [`ItemKind`], by one object:
6//!
7//! ```yaml
8//! status_mapping:
9//!   todo: Todo                                 # every kind
10//!   draft: null                                # disabled for every kind
11//!   done: { task: Done, project: Completed }   # per kind
12//! ```
13//!
14//! The keys are status categories, spelled exactly as [`StatusCategory`] serializes. A value
15//! is a non-blank name for every kind, `null` to disable the category for every kind, or an
16//! object with the optional keys `task` and `project`, each a non-blank name, where a key left
17//! out leaves the category unmapped for that kind. Anything else is refused as the
18//! configuration is read, naming the category and the offending part.
19//!
20//! Defined here rather than in each plugin because it is one concept with one grammar: two
21//! spellings of it would let a configuration that loads under one source be refused, or read
22//! differently, under another.
23
24use std::{borrow::Cow, fmt};
25
26use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
27use serde::{
28    Deserialize, Deserializer, Serialize, Serializer,
29    de::{self, MapAccess, Visitor},
30    ser::SerializeMap,
31};
32use serde_json::Value;
33
34use crate::{ItemKind, SourceError, SourceName, StatusCategory};
35
36/// Every status category, in the contract's own order — the order a mapping is held and
37/// reported in.
38const CATEGORIES: [StatusCategory; 8] = [
39    StatusCategory::Draft,
40    StatusCategory::Backlog,
41    StatusCategory::Todo,
42    StatusCategory::Queued,
43    StatusCategory::InProgress,
44    StatusCategory::Done,
45    StatusCategory::Cancelled,
46    StatusCategory::Unknown,
47];
48
49/// Where `category` sits in [`CATEGORIES`] — an exhaustive match, so a category the contract
50/// adds fails to compile here rather than going unmapped.
51const fn position(category: StatusCategory) -> usize {
52    match category {
53        StatusCategory::Draft => 0,
54        StatusCategory::Backlog => 1,
55        StatusCategory::Todo => 2,
56        StatusCategory::Queued => 3,
57        StatusCategory::InProgress => 4,
58        StatusCategory::Done => 5,
59        StatusCategory::Cancelled => 6,
60        StatusCategory::Unknown => 7,
61    }
62}
63
64const _: () = {
65    let mut index = 0;
66    while index < CATEGORIES.len() {
67        assert!(position(CATEGORIES[index]) == index);
68        index += 1;
69    }
70};
71
72/// A category as the configuration spells it — `in-progress`, `queued`.
73const fn category_key(category: StatusCategory) -> &'static str {
74    match category {
75        StatusCategory::Draft => "draft",
76        StatusCategory::Backlog => "backlog",
77        StatusCategory::Todo => "todo",
78        StatusCategory::Queued => "queued",
79        StatusCategory::InProgress => "in-progress",
80        StatusCategory::Done => "done",
81        StatusCategory::Cancelled => "cancelled",
82        StatusCategory::Unknown => "unknown",
83    }
84}
85
86/// An item kind as the configuration spells it.
87const fn kind_key(kind: ItemKind) -> &'static str {
88    match kind {
89        ItemKind::Task => "task",
90        ItemKind::Project => "project",
91    }
92}
93
94/// One status name a source's backend holds: a board option, a workflow state, a project
95/// status.
96///
97/// Validated on the way in, so a blank name — which nothing in any backend can be called — is
98/// a name this type cannot hold. Compared case-insensitively wherever a source matches it,
99/// because every backend this product reads matches its own names that way.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(try_from = "String", into = "String")]
102pub struct StatusName(String);
103
104impl StatusName {
105    /// The name, as the configuration spells it.
106    #[must_use]
107    pub fn as_str(&self) -> &str {
108        &self.0
109    }
110
111    /// Whether `other` is this name, as every backend compares its own: ignoring ASCII case.
112    #[must_use]
113    pub fn matches(&self, other: &str) -> bool {
114        self.0.eq_ignore_ascii_case(other)
115    }
116}
117
118impl TryFrom<String> for StatusName {
119    type Error = String;
120
121    fn try_from(name: String) -> Result<Self, Self::Error> {
122        if name.trim().is_empty() {
123            Err("a status name cannot be blank".to_owned())
124        } else {
125            Ok(Self(name))
126        }
127    }
128}
129
130impl From<StatusName> for String {
131    fn from(name: StatusName) -> Self {
132        name.0
133    }
134}
135
136impl fmt::Display for StatusName {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        f.write_str(&self.0)
139    }
140}
141
142impl JsonSchema for StatusName {
143    fn schema_name() -> Cow<'static, str> {
144        "StatusName".into()
145    }
146
147    fn json_schema(_: &mut SchemaGenerator) -> Schema {
148        json_schema!({
149            "description": "One status name a source's backend holds, matched ignoring case. Never blank.",
150            "type": "string",
151            "minLength": 1,
152            "pattern": "\\S"
153        })
154    }
155}
156
157/// What one category of a `status_mapping` names: one name for every kind, or a name for
158/// each kind it maps.
159///
160/// Four variants rather than a pair of optional names, so a per-kind object naming no kind —
161/// which the grammar refuses — is a value this type cannot hold.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum StatusNames {
164    /// The same name for a task and for a project: the bare-string form.
165    Every(StatusName),
166    /// `{ task: … }`: a name for a task, and the category unmapped for a project.
167    Task(StatusName),
168    /// `{ project: … }`: a name for a project, and the category unmapped for a task.
169    Project(StatusName),
170    /// `{ task: …, project: … }`: a name for each.
171    PerKind {
172        /// The name for a task.
173        task: StatusName,
174        /// The name for a project.
175        project: StatusName,
176    },
177}
178
179impl StatusNames {
180    /// The name this gives `kind`, or `None` where it leaves that kind unmapped.
181    #[must_use]
182    pub fn for_kind(&self, kind: ItemKind) -> Option<&StatusName> {
183        match (self, kind) {
184            (Self::Every(name), _)
185            | (Self::Task(name), ItemKind::Task)
186            | (Self::Project(name), ItemKind::Project)
187            | (Self::PerKind { task: name, .. }, ItemKind::Task)
188            | (Self::PerKind { project: name, .. }, ItemKind::Project) => Some(name),
189            (Self::Task(_), ItemKind::Project) | (Self::Project(_), ItemKind::Task) => None,
190        }
191    }
192
193    /// One configured value, read with the category it is under so a refusal can name it.
194    fn parse(category: StatusCategory, value: Value) -> Result<Self, String> {
195        let at = category_key(category);
196        let name = |value: Value, path: &str| -> Result<StatusName, String> {
197            match value {
198                Value::String(name) => StatusName::try_from(name).map_err(|_| {
199                    format!("status_mapping.{path} is blank; a status name cannot be blank")
200                }),
201                Value::Null => Err(format!(
202                    "status_mapping.{path} is null; leave that key out to leave {at} unmapped for \
203                     that kind, or set status_mapping.{at} to null to disable {at} for every kind"
204                )),
205                other => Err(format!(
206                    "status_mapping.{path} is {other}, which is not a status name; write the \
207                     name as a string"
208                )),
209            }
210        };
211        match value {
212            Value::String(_) => name(value, at).map(Self::Every),
213            Value::Object(object) => {
214                if object.is_empty() {
215                    return Err(format!(
216                        "status_mapping.{at} is an empty object, which maps no kind; to disable \
217                         {at} for every kind, write null"
218                    ));
219                }
220                let mut task = None;
221                let mut project = None;
222                for (key, value) in object {
223                    match key.as_str() {
224                        "task" => task = Some(name(value, &format!("{at}.task"))?),
225                        "project" => project = Some(name(value, &format!("{at}.project"))?),
226                        other => {
227                            return Err(format!(
228                                "status_mapping.{at} names {other:?}, which is not an item kind; \
229                                 the kinds are task and project"
230                            ));
231                        }
232                    }
233                }
234                Ok(match (task, project) {
235                    (Some(task), Some(project)) => Self::PerKind { task, project },
236                    (Some(task), None) => Self::Task(task),
237                    (None, Some(project)) => Self::Project(project),
238                    (None, None) => unreachable!("a non-empty object of known keys names a kind"),
239                })
240            }
241            other => Err(format!(
242                "status_mapping.{at} is {other}, which is none of a status name, null, or an \
243                 object with the keys task and project"
244            )),
245        }
246    }
247}
248
249impl Serialize for StatusNames {
250    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
251        match self {
252            Self::Every(name) => name.serialize(serializer),
253            Self::Task(task) => {
254                let mut map = serializer.serialize_map(Some(1))?;
255                map.serialize_entry("task", task)?;
256                map.end()
257            }
258            Self::Project(project) => {
259                let mut map = serializer.serialize_map(Some(1))?;
260                map.serialize_entry("project", project)?;
261                map.end()
262            }
263            Self::PerKind { task, project } => {
264                let mut map = serializer.serialize_map(Some(2))?;
265                map.serialize_entry("task", task)?;
266                map.serialize_entry("project", project)?;
267                map.end()
268            }
269        }
270    }
271}
272
273impl JsonSchema for StatusNames {
274    fn schema_name() -> Cow<'static, str> {
275        "StatusNames".into()
276    }
277
278    fn json_schema(generator: &mut SchemaGenerator) -> Schema {
279        let name = generator.subschema_for::<StatusName>();
280        let only = |kind: &str| {
281            json_schema!({
282                "type": "object",
283                "properties": { (kind): name },
284                "required": [kind],
285                "additionalProperties": false
286            })
287        };
288        json_schema!({
289            "description": "What one category of a status_mapping names: one name for every item kind, or an object naming it for a task, for a project, or for each. A kind the object leaves out leaves the category unmapped for that kind.",
290            "anyOf": [
291                name,
292                only("task"),
293                only("project"),
294                {
295                    "type": "object",
296                    "properties": { "task": name, "project": name },
297                    "required": ["task", "project"],
298                    "additionalProperties": false
299                }
300            ]
301        })
302    }
303}
304
305/// Why a mapping gives no name for one category and one kind.
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307pub enum UnmappedStatus {
308    /// The mapping does not mention the category at all.
309    Unconfigured,
310    /// The mapping sets the category to `null`, disabling it for every kind.
311    Disabled,
312    /// The mapping names the category for the other kind only.
313    OtherKindOnly,
314}
315
316impl UnmappedStatus {
317    /// The refusal of a status write `source` has no name for: naming the source, the kind,
318    /// the category, why, and the key to set.
319    #[must_use]
320    pub fn refusal(
321        self,
322        source: &SourceName,
323        category: StatusCategory,
324        kind: ItemKind,
325    ) -> SourceError {
326        let at = category_key(category);
327        let of = kind_key(kind);
328        let why = match self {
329            Self::Unconfigured => format!("its status_mapping does not name {at}"),
330            Self::Disabled => {
331                format!("its status_mapping sets {at} to null, disabling it for every kind")
332            }
333            Self::OtherKindOnly => format!("its status_mapping names {at} for the other kind only"),
334        };
335        SourceError::Refused {
336            message: format!(
337                "source {source} has no {of} status name for {at}: {why}; next: set \
338                 status_mapping.{at}.{of} of this source to the {of} status {at} should be \
339                 written as"
340            ),
341        }
342    }
343}
344
345/// One source's `status_mapping`: for each status category it mentions, the names it gives
346/// each item kind, or `null`.
347///
348/// Held in the contract's category order with each category at most once, so iterating it
349/// reports in that order and a category named twice is a state it cannot hold.
350#[derive(Debug, Clone, Default, PartialEq, Eq)]
351pub struct StatusMapping {
352    entries: Vec<(StatusCategory, Option<StatusNames>)>,
353}
354
355impl StatusMapping {
356    /// Whether the mapping mentions no category at all.
357    #[must_use]
358    pub fn is_empty(&self) -> bool {
359        self.entries.is_empty()
360    }
361
362    /// Whether the mapping mentions `category`, as a name or as `null`.
363    #[must_use]
364    pub fn mentions(&self, category: StatusCategory) -> bool {
365        self.entries.iter().any(|(held, _)| *held == category)
366    }
367
368    /// What the mapping says of `category`: `None` where it does not mention it, `Some(None)`
369    /// where it sets it to `null`, and the names otherwise.
370    #[must_use]
371    pub fn entry(&self, category: StatusCategory) -> Option<Option<&StatusNames>> {
372        self.entries
373            .iter()
374            .find(|(held, _)| *held == category)
375            .map(|(_, names)| names.as_ref())
376    }
377
378    /// The name `category` is written as for `kind`, or why the mapping gives none.
379    ///
380    /// # Errors
381    ///
382    /// The [`UnmappedStatus`] saying which of the three ways the mapping gives no name.
383    pub fn name_for(
384        &self,
385        category: StatusCategory,
386        kind: ItemKind,
387    ) -> Result<&StatusName, UnmappedStatus> {
388        match self.entry(category) {
389            None => Err(UnmappedStatus::Unconfigured),
390            Some(None) => Err(UnmappedStatus::Disabled),
391            Some(Some(names)) => names.for_kind(kind).ok_or(UnmappedStatus::OtherKindOnly),
392        }
393    }
394
395    /// Every name the mapping gives `kind`, with its category, in category order.
396    pub fn names(&self, kind: ItemKind) -> impl Iterator<Item = (StatusCategory, &StatusName)> {
397        self.entries.iter().filter_map(move |(category, names)| {
398            names
399                .as_ref()
400                .and_then(|names| names.for_kind(kind))
401                .map(|name| (*category, name))
402        })
403    }
404
405    /// The category a `kind` name reads as, when the mapping names it for that kind.
406    #[must_use]
407    pub fn category_of(&self, kind: ItemKind, name: &str) -> Option<StatusCategory> {
408        self.names(kind)
409            .find_map(|(category, mapped)| mapped.matches(name).then_some(category))
410    }
411
412    /// Refuse two categories sent to one name of one kind, ignoring case: that name could read
413    /// back as only one of them.
414    ///
415    /// Takes the names to compare rather than reading this mapping's own, so a source whose
416    /// unmentioned categories keep shipped defaults compares what it really resolves to.
417    ///
418    /// # Errors
419    ///
420    /// [`SourceError::Config`] naming the source, the kind, both categories and the name.
421    pub fn distinct<'a>(
422        source: &SourceName,
423        kind: ItemKind,
424        names: impl IntoIterator<Item = (StatusCategory, &'a str)>,
425    ) -> Result<(), SourceError> {
426        let mut seen: Vec<(StatusCategory, &str)> = Vec::new();
427        for (category, name) in names {
428            if let Some((other, _)) = seen
429                .iter()
430                .find(|(_, held)| held.eq_ignore_ascii_case(name))
431            {
432                return Err(SourceError::Config {
433                    message: format!(
434                        "source {source}: status_mapping sends both {} and {} to the {} status \
435                         {name:?}; one name cannot read back as two categories, so map one of \
436                         them to another name",
437                        category_key(*other),
438                        category_key(category),
439                        kind_key(kind),
440                    ),
441                });
442            }
443            seen.push((category, name));
444        }
445        Ok(())
446    }
447}
448
449impl Serialize for StatusMapping {
450    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
451        let mut map = serializer.serialize_map(Some(self.entries.len()))?;
452        for (category, names) in &self.entries {
453            map.serialize_entry(category_key(*category), names)?;
454        }
455        map.end()
456    }
457}
458
459impl<'de> Deserialize<'de> for StatusMapping {
460    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
461        struct Entries;
462
463        impl<'de> Visitor<'de> for Entries {
464            type Value = StatusMapping;
465
466            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
467                f.write_str("a status_mapping object keyed by status category")
468            }
469
470            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<StatusMapping, A::Error> {
471                let mut entries: Vec<(StatusCategory, Option<StatusNames>)> = Vec::new();
472                while let Some(key) = map.next_key::<String>()? {
473                    let category = CATEGORIES
474                        .into_iter()
475                        .find(|category| category_key(*category) == key)
476                        .ok_or_else(|| {
477                            de::Error::custom(format!(
478                                "status_mapping names {key:?}, which is not a status category; \
479                                 the categories are {}",
480                                CATEGORIES.map(category_key).join(", ")
481                            ))
482                        })?;
483                    if entries.iter().any(|(held, _)| *held == category) {
484                        return Err(de::Error::custom(format!(
485                            "status_mapping names {key} twice"
486                        )));
487                    }
488                    let value: Value = map.next_value()?;
489                    let names = match value {
490                        Value::Null => None,
491                        value => {
492                            Some(StatusNames::parse(category, value).map_err(de::Error::custom)?)
493                        }
494                    };
495                    entries.push((category, names));
496                }
497                entries.sort_by_key(|(category, _)| position(*category));
498                Ok(StatusMapping { entries })
499            }
500        }
501
502        deserializer.deserialize_map(Entries)
503    }
504}
505
506impl JsonSchema for StatusMapping {
507    fn schema_name() -> Cow<'static, str> {
508        "StatusMapping".into()
509    }
510
511    fn json_schema(generator: &mut SchemaGenerator) -> Schema {
512        let category = generator.subschema_for::<StatusCategory>();
513        let names = generator.subschema_for::<StatusNames>();
514        json_schema!({
515            "description": "Which name each status category is, for each item kind. Keyed by status category; each value is one name for every kind, null to disable the category for every kind, or an object naming it for a task, a project or each.",
516            "type": "object",
517            "propertyNames": category,
518            "additionalProperties": { "anyOf": [names, { "type": "null" }] }
519        })
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use serde_json::json;
526
527    use super::*;
528
529    fn read(value: Value) -> Result<StatusMapping, String> {
530        serde_json::from_value(value).map_err(|error| error.to_string())
531    }
532
533    #[test]
534    fn every_form_the_grammar_admits_loads_and_answers_by_kind() {
535        let mapping = read(json!({
536            "todo": "Todo",
537            "draft": null,
538            "done": {"task": "Done", "project": "Completed"},
539            "queued": {"task": "Queued"},
540            "backlog": {"project": "Idea"},
541        }))
542        .unwrap();
543        let name = |category, kind| mapping.name_for(category, kind).map(StatusName::as_str);
544        assert_eq!(name(StatusCategory::Todo, ItemKind::Task), Ok("Todo"));
545        assert_eq!(name(StatusCategory::Todo, ItemKind::Project), Ok("Todo"));
546        assert_eq!(
547            name(StatusCategory::Draft, ItemKind::Task),
548            Err(UnmappedStatus::Disabled)
549        );
550        assert_eq!(
551            name(StatusCategory::Done, ItemKind::Project),
552            Ok("Completed")
553        );
554        assert_eq!(
555            name(StatusCategory::Queued, ItemKind::Project),
556            Err(UnmappedStatus::OtherKindOnly)
557        );
558        assert_eq!(
559            name(StatusCategory::Backlog, ItemKind::Task),
560            Err(UnmappedStatus::OtherKindOnly)
561        );
562        assert_eq!(
563            name(StatusCategory::Unknown, ItemKind::Task),
564            Err(UnmappedStatus::Unconfigured)
565        );
566        assert_eq!(
567            mapping.category_of(ItemKind::Project, "completed"),
568            Some(StatusCategory::Done)
569        );
570        assert_eq!(mapping.category_of(ItemKind::Task, "Completed"), None);
571        // Held and written back in the contract's order, every form as it was given.
572        assert_eq!(
573            serde_json::to_value(&mapping).unwrap(),
574            json!({"draft": null, "backlog": {"project": "Idea"}, "todo": "Todo",
575                   "queued": {"task": "Queued"}, "done": {"task": "Done", "project": "Completed"}})
576        );
577        assert_eq!(
578            read(serde_json::to_value(&mapping).unwrap()).unwrap(),
579            mapping
580        );
581    }
582
583    #[test]
584    fn every_form_the_grammar_refuses_is_refused_naming_the_part() {
585        for (value, said) in [
586            (
587                json!({"doing": "Doing"}),
588                "\"doing\", which is not a status category",
589            ),
590            (
591                json!({"done": {}}),
592                "status_mapping.done is an empty object, which maps no kind; to disable done for every kind, write null",
593            ),
594            (
595                json!({"done": {"task": "Done", "epic": "Done"}}),
596                "status_mapping.done names \"epic\", which is not an item kind",
597            ),
598            (
599                json!({"done": {"task": null}}),
600                "status_mapping.done.task is null",
601            ),
602            (
603                json!({"done": {"project": " "}}),
604                "status_mapping.done.project is blank",
605            ),
606            (json!({"done": ""}), "status_mapping.done is blank"),
607            (json!({"done": 3}), "status_mapping.done is 3"),
608            (
609                json!({"done": {"task": 3}}),
610                "status_mapping.done.task is 3",
611            ),
612        ] {
613            let error = read(value.clone()).unwrap_err();
614            assert!(
615                error.contains(said),
616                "{value} was refused with {error:?}, not naming {said:?}"
617            );
618        }
619    }
620
621    /// The keys this grammar reads and writes, and the kinds its refusals name, are the
622    /// contract's own serialization of `StatusCategory` and `ItemKind`: a variant renamed on the
623    /// wire there and not here fails this rather than a configuration.
624    #[test]
625    fn every_key_is_the_contracts_own_spelling_of_its_category_and_kind() {
626        for category in CATEGORIES {
627            assert_eq!(
628                serde_json::to_value(category).unwrap(),
629                json!(category_key(category))
630            );
631        }
632        for kind in [ItemKind::Task, ItemKind::Project] {
633            assert_eq!(serde_json::to_value(kind).unwrap(), json!(kind_key(kind)));
634        }
635    }
636
637    #[test]
638    fn two_categories_on_one_name_of_one_kind_are_refused_ignoring_case() {
639        let source = SourceName::new("work").unwrap();
640        let error = StatusMapping::distinct(
641            &source,
642            ItemKind::Task,
643            [
644                (StatusCategory::Todo, "Todo"),
645                (StatusCategory::Queued, "TODO"),
646            ],
647        )
648        .unwrap_err();
649        assert!(
650            error.to_string().contains(
651                "source work: status_mapping sends both todo and queued to the task status \"TODO\""
652            ),
653            "{error}"
654        );
655        StatusMapping::distinct(
656            &source,
657            ItemKind::Task,
658            [
659                (StatusCategory::Todo, "Todo"),
660                (StatusCategory::Queued, "Queued"),
661            ],
662        )
663        .unwrap();
664    }
665
666    #[test]
667    fn an_unmapped_status_is_refused_naming_the_source_kind_category_and_key() {
668        let source = SourceName::new("work").unwrap();
669        let message = UnmappedStatus::OtherKindOnly
670            .refusal(&source, StatusCategory::InProgress, ItemKind::Project)
671            .to_string();
672        for part in [
673            "source work",
674            "project status name for in-progress",
675            "status_mapping.in-progress.project",
676        ] {
677            assert!(message.contains(part), "{message} does not name {part}");
678        }
679    }
680}