1use 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
36const 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
49const 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
72const 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
86const fn kind_key(kind: ItemKind) -> &'static str {
88 match kind {
89 ItemKind::Task => "task",
90 ItemKind::Project => "project",
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(try_from = "String", into = "String")]
102pub struct StatusName(String);
103
104impl StatusName {
105 #[must_use]
107 pub fn as_str(&self) -> &str {
108 &self.0
109 }
110
111 #[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#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum StatusNames {
164 Every(StatusName),
166 Task(StatusName),
168 Project(StatusName),
170 PerKind {
172 task: StatusName,
174 project: StatusName,
176 },
177}
178
179impl StatusNames {
180 #[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307pub enum UnmappedStatus {
308 Unconfigured,
310 Disabled,
312 OtherKindOnly,
314}
315
316impl UnmappedStatus {
317 #[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
351pub struct StatusMapping {
352 entries: Vec<(StatusCategory, Option<StatusNames>)>,
353}
354
355impl StatusMapping {
356 #[must_use]
358 pub fn is_empty(&self) -> bool {
359 self.entries.is_empty()
360 }
361
362 #[must_use]
364 pub fn mentions(&self, category: StatusCategory) -> bool {
365 self.entries.iter().any(|(held, _)| *held == category)
366 }
367
368 #[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 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 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 #[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 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 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 #[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}