Skip to main content

ytcli/api/
parse.rs

1//! Turning Tracker's payloads into our models.
2//!
3//! Tracker returns most human-readable values as objects with `display`, `key`,
4//! `id` and `self` members, and the useful part is not always in the same one.
5//! Rather than deriving `Deserialize` against a moving target, we pull out what
6//! we render and keep everything else in `extra`, so an upstream addition is
7//! carried through instead of breaking the parse (ADR 4).
8
9use serde_json::{Map, Value};
10
11use crate::api::models::{
12    Attachment, Change, ChecklistItem, Comment, DictEntry, Entity, FieldChange, Issue, Link,
13    LinkKind, Person, RemoteLink, User, Worklog,
14};
15
16/// System fields we map explicitly. Anything outside this set is treated as a
17/// custom field and lands in `extra`.
18const KNOWN: &[&str] = &[
19    "key",
20    // Counters and bookkeeping Tracker returns on every issue. They are not
21    // custom fields, and counting them as such made every issue look like it had
22    // several — which is exactly the noise the summary exists to avoid.
23    "commentWithExternalMessageCount",
24    "votes",
25    "votedBy",
26    "unique",
27    "boards",
28    "access",
29    "followers",
30    "checklistDone",
31    "checklistTotal",
32    "checklistItems",
33    "emailCreatedBy",
34    "emailTo",
35    "emailFrom",
36    "summary",
37    "status",
38    "type",
39    "priority",
40    "queue",
41    "assignee",
42    "createdBy",
43    "createdAt",
44    "updatedAt",
45    "description",
46    "commentWithoutExternalMessageCount",
47    "id",
48    "self",
49    "version",
50    "aliases",
51    "lastCommentUpdatedAt",
52    "statusStartTime",
53    "updatedBy",
54    "previousStatus",
55    "previousStatusLastAssignee",
56    "favorite",
57    "pendingReplyFrom",
58];
59
60/// The label of a Tracker reference object: `display` if present, else `key`,
61/// else `id`, else the value itself when it is a bare string.
62fn label(value: Option<&Value>) -> Option<String> {
63    let value = value?;
64    if let Some(text) = value.as_str() {
65        return Some(text.to_owned());
66    }
67    for member in ["display", "key", "id"] {
68        if let Some(text) = value.get(member).and_then(Value::as_str) {
69            return Some(text.to_owned());
70        }
71    }
72    None
73}
74
75/// The key of a reference object, falling back to its label.
76///
77/// Queues are addressed by key everywhere — it is the `PROJ` in `PROJ-1` — so
78/// showing the prettier `display` name would print something the caller cannot
79/// then type back into another command.
80/// The stable key of a referenced object, and nothing else.
81///
82/// Unlike [`key_label`] this does not fall back to a display name: half a
83/// vocabulary is worse than none, since a caller matching on `closed` would
84/// silently start matching a localised word that happens to be there.
85fn key_of(value: Option<&Value>) -> Option<String> {
86    value?
87        .get("key")
88        .and_then(Value::as_str)
89        .map(ToOwned::to_owned)
90}
91
92fn key_label(value: Option<&Value>) -> Option<String> {
93    let value = value?;
94    if let Some(key) = value.get("key").and_then(Value::as_str) {
95        return Some(key.to_owned());
96    }
97    label(Some(value))
98}
99
100fn user(value: Option<&Value>) -> Option<User> {
101    let value = value?;
102    Some(User {
103        id: value
104            .get("id")
105            .and_then(Value::as_str)
106            .unwrap_or_default()
107            .to_owned(),
108        login: value
109            .get("login")
110            .and_then(Value::as_str)
111            .map(ToOwned::to_owned),
112        display: value
113            .get("display")
114            .and_then(Value::as_str)
115            .map(ToOwned::to_owned),
116    })
117}
118
119/// Parse a Tracker timestamp.
120///
121/// The API emits offsets as `+0300` rather than `+03:00`, which is valid ISO 8601
122/// but not what a strict RFC 3339 parser accepts, so the compact form is widened
123/// before parsing. An unparseable date is dropped rather than failing the whole
124/// issue: a missing `updated` line is a much smaller problem than a command that
125/// refuses to show an issue at all.
126fn timestamp(value: Option<&Value>) -> Option<jiff::Timestamp> {
127    let text = value?.as_str()?;
128
129    if let Ok(parsed) = text.parse::<jiff::Timestamp>() {
130        return Some(parsed);
131    }
132
133    let widened = widen_offset(text);
134    match widened.parse::<jiff::Timestamp>() {
135        Ok(parsed) => Some(parsed),
136        Err(error) => {
137            tracing::debug!(%text, %error, "unparseable timestamp, omitted");
138            None
139        }
140    }
141}
142
143/// `2026-08-27T10:00:00.000+0300` -> `2026-08-27T10:00:00.000+03:00`.
144fn widen_offset(text: &str) -> String {
145    let bytes = text.as_bytes();
146    let Some(sign_at) = bytes
147        .iter()
148        .rposition(|byte| *byte == b'+' || *byte == b'-')
149    else {
150        return text.to_owned();
151    };
152
153    // Only the trailing `±HHMM` form needs widening, and only when it really is
154    // a four-digit offset rather than part of the date.
155    let offset = &text[sign_at + 1..];
156    if offset.len() != 4 || !offset.bytes().all(|byte| byte.is_ascii_digit()) {
157        return text.to_owned();
158    }
159
160    format!("{}{}:{}", &text[..=sign_at], &offset[..2], &offset[2..])
161}
162
163/// Map a link type id and our side of it onto our vocabulary.
164///
165/// Keyed on `type.id`, which is stable and English, rather than on the
166/// `inward`/`outward` labels, which come back in the organisation's language —
167/// a Russian-locale organisation answers "Связана", and an English word list
168/// silently turns every link into "links".
169///
170/// `direction` says which end we are on, and the rule is one rule: the kind has
171/// to mean what Tracker's own label for *our* side means. `subtask` outward is
172/// labelled "родительская задача" — we are the parent, so the other issue is
173/// the subtask; `depends` outward is labelled "зависит от" — we are the one
174/// that depends.
175///
176/// The `depends` pair was the wrong way round until a round trip against a real
177/// organisation caught it, and nothing else could have: written from one end
178/// the wrong mapping is self-consistent, and the fixtures were written from the
179/// same belief as the code. Both ends, or it proves nothing.
180fn link_kind(type_id: &str, inward: bool) -> LinkKind {
181    match (type_id, inward) {
182        ("subtask", true) => LinkKind::Parent,
183        ("subtask", false) => LinkKind::Subtask,
184        ("depends", true) => LinkKind::IsDependentBy,
185        ("depends", false) => LinkKind::Depends,
186        ("duplicates" | "duplicate", true) => LinkKind::Duplicates,
187        ("duplicates" | "duplicate", false) => LinkKind::IsDuplicatedBy,
188        ("epic", true) => LinkKind::HasEpic,
189        ("epic", false) => LinkKind::Epic,
190        ("relates", _) => LinkKind::Relates,
191        _ => LinkKind::Other,
192    }
193}
194
195/// Parse one entry of `GET /v3/issues/{key}/links`.
196#[must_use]
197pub fn link(value: &Value) -> Option<Link> {
198    let object = value.get("object")?;
199    let kind_object = value.get("type");
200    let inward = value.get("direction").and_then(Value::as_str) == Some("inward");
201
202    let type_id = kind_object
203        .and_then(|kind| kind.get("id"))
204        .and_then(Value::as_str)
205        .unwrap_or_default();
206
207    // Tracker's own wording for our side of the link, kept for the types we do
208    // not recognise so the output still says something true.
209    let relation = kind_object
210        .and_then(|kind| kind.get(if inward { "inward" } else { "outward" }))
211        .and_then(Value::as_str)
212        .map(str::to_lowercase);
213
214    Some(Link {
215        // A number on the wire in some organisations, a string in others, and
216        // absent only in a payload we have never seen. A link with no id is
217        // still a link: dropping the row would make it invisible, and this
218        // command exists so that invisible and absent are not the same thing.
219        id: match value.get("id") {
220            Some(Value::String(id)) => id.clone(),
221            Some(other) => other.to_string(),
222            None => String::new(),
223        },
224        kind: link_kind(type_id, inward),
225        relation,
226        key: object.get("key").and_then(Value::as_str)?.to_owned(),
227        summary: label(object.get("display")),
228        status: label(object.get("status")),
229    })
230}
231
232/// One link out of Tracker.
233///
234/// Everything but the id is optional on purpose: the object at the far end is
235/// described by an application we know nothing about, and a link whose payload
236/// is thinner than the documented shape is still a link worth printing.
237#[must_use]
238pub fn remote_link(value: &Value) -> Option<RemoteLink> {
239    let object = value.get("object");
240    let inward = value.get("direction").and_then(Value::as_str) == Some("inward");
241
242    Some(RemoteLink {
243        id: match value.get("id")? {
244            Value::String(id) => id.clone(),
245            other => other.to_string(),
246        },
247        relation: value
248            .get("type")
249            .and_then(|kind| {
250                kind.get(if inward { "inward" } else { "outward" })
251                    .or_else(|| kind.get("id"))
252            })
253            .and_then(Value::as_str)
254            .map(str::to_lowercase),
255        application: object
256            .and_then(|object| object.get("application"))
257            .and_then(|app| app.get("name").or_else(|| app.get("id")))
258            .and_then(Value::as_str)
259            .map(ToOwned::to_owned),
260        key: object
261            .and_then(|object| object.get("key"))
262            .and_then(Value::as_str)
263            .map(ToOwned::to_owned),
264        title: label(object.and_then(|object| object.get("display"))),
265    })
266}
267
268/// Parse one project, portfolio or goal.
269///
270/// Everything worth showing sits under `fields`; the envelope carries only
271/// identity.
272#[must_use]
273pub fn entity(value: &Value) -> Option<Entity> {
274    let fields = value.get("fields");
275    let field = |name: &str| fields.and_then(|fields| fields.get(name));
276
277    Some(Entity {
278        id: value.get("id").and_then(Value::as_str)?.to_owned(),
279        short_id: value.get("shortId").and_then(Value::as_i64),
280        entity_type: value
281            .get("entityType")
282            .and_then(Value::as_str)
283            .map(ToOwned::to_owned),
284        summary: field("summary")
285            .and_then(Value::as_str)
286            .unwrap_or_default()
287            .to_owned(),
288        status: label(field("entityStatus")),
289        lead: user(field("lead")),
290        start: field("start")
291            .and_then(Value::as_str)
292            .map(ToOwned::to_owned),
293        end: field("end").and_then(Value::as_str).map(ToOwned::to_owned),
294        description: field("description")
295            .and_then(Value::as_str)
296            .map(ToOwned::to_owned),
297        parent: parent_entity(field("parentEntity")),
298        version: value.get("version").and_then(Value::as_u64),
299    })
300}
301
302/// The id out of `parentEntity`, in whichever shape it arrives.
303///
304/// Writes take an object (`{"primary": id, "secondary": [...]}`), and reads have
305/// been seen to answer with the bare id. Both are accepted rather than guessed
306/// at, because an entity whose parent silently reads as absent is worse than one
307/// that costs a few lines here.
308fn parent_entity(value: Option<&Value>) -> Option<String> {
309    fn id_of(value: &Value) -> Option<String> {
310        match value {
311            Value::String(id) => Some(id.clone()),
312            Value::Number(id) => Some(id.to_string()),
313            // A read answers `{"primary": {"id": …, "display": …}}`; a write
314            // takes `{"primary": "<id>"}`. Both arrive here.
315            Value::Object(map) => map.get("id").and_then(id_of),
316            _ => None,
317        }
318    }
319
320    match value? {
321        Value::Object(map) => map.get("primary").and_then(id_of),
322        other => id_of(other),
323    }
324}
325
326/// Parse one entry of `GET /v3/issues/{key}/attachments`.
327#[must_use]
328pub fn attachment(value: &Value) -> Option<Attachment> {
329    Some(Attachment {
330        id: match value.get("id")? {
331            Value::String(text) => text.clone(),
332            other => other.to_string(),
333        },
334        name: value
335            .get("name")
336            .and_then(Value::as_str)
337            .unwrap_or_default()
338            .to_owned(),
339        size: value.get("size").and_then(Value::as_u64),
340        mimetype: value
341            .get("mimetype")
342            .and_then(Value::as_str)
343            .map(ToOwned::to_owned),
344        author: user(value.get("createdBy")),
345        created_at: timestamp(value.get("createdAt")),
346        content: value
347            .get("content")
348            .and_then(Value::as_str)
349            .map(ToOwned::to_owned),
350    })
351}
352
353/// Parse one entry of `GET /v3/issues/{key}/comments`.
354#[must_use]
355pub fn comment(value: &Value) -> Option<Comment> {
356    Some(Comment {
357        id: value
358            .get("id")
359            .map(|id| match id {
360                Value::String(text) => text.clone(),
361                other => other.to_string(),
362            })
363            .unwrap_or_default(),
364        text: value
365            .get("text")
366            .and_then(Value::as_str)
367            .unwrap_or_default()
368            .to_owned(),
369        author: user(value.get("createdBy")),
370        created_at: timestamp(value.get("createdAt")),
371    })
372}
373
374/// Parse one entry of `GET /v3/issues/{key}/worklog`.
375#[must_use]
376pub fn worklog(value: &Value) -> Option<Worklog> {
377    Some(Worklog {
378        id: identifier(value.get("id"))?,
379        duration: value
380            .get("duration")
381            .and_then(Value::as_str)
382            .unwrap_or_default()
383            .to_owned(),
384        author: user(value.get("createdBy")),
385        start: timestamp(value.get("start")),
386        comment: value
387            .get("comment")
388            .and_then(Value::as_str)
389            .map(str::to_owned),
390        issue: key_label(value.get("issue")),
391    })
392}
393
394/// Parse one entry of `GET /v3/issues/{key}/checklistItems`.
395#[must_use]
396pub fn checklist_item(value: &Value) -> Option<ChecklistItem> {
397    Some(ChecklistItem {
398        id: identifier(value.get("id"))?,
399        text: value
400            .get("text")
401            .and_then(Value::as_str)
402            .unwrap_or_default()
403            .to_owned(),
404        checked: value
405            .get("checked")
406            .and_then(Value::as_bool)
407            .unwrap_or(false),
408        assignee: user(value.get("assignee")),
409        deadline: value
410            .get("deadline")
411            .and_then(|deadline| deadline.get("date").or(Some(deadline)))
412            .and_then(Value::as_str)
413            .map(str::to_owned),
414    })
415}
416
417/// An id, whether Tracker sent it as a string or a number.
418///
419/// Worklog and checklist ids come back as numbers on some endpoints and strings
420/// on others, and a caller passes whichever they were given straight back.
421fn identifier(value: Option<&Value>) -> Option<String> {
422    match value? {
423        Value::String(text) => Some(text.clone()),
424        Value::Number(number) => Some(number.to_string()),
425        _ => None,
426    }
427}
428
429/// The name a caller can actually type for a custom field.
430///
431/// Tracker returns them prefixed with the queue's opaque id —
432/// `603bd9b6cdc7ba0d2f4b1a55--component` — and accepts the trailing segment
433/// back. `queue fields` has always printed the short form; the issue view
434/// printed the raw one, which meant the line whose whole purpose is to say what
435/// to ask for next named a key `--fields` would not take.
436fn custom_field_key(key: &str) -> String {
437    key.rsplit("--").next().unwrap_or(key).to_owned()
438}
439
440/// Parse `GET /v3/issues/{key}` (or one element of a search result).
441///
442/// Returns `None` only when the payload has no key, which means it is not an
443/// issue at all.
444#[must_use]
445pub fn issue(value: &Value) -> Option<Issue> {
446    let object = value.as_object()?;
447
448    let mut extra = Map::new();
449    for (key, member) in object {
450        if KNOWN.contains(&key.as_str()) {
451            continue;
452        }
453        // A custom field that is set to nothing is not worth counting: it would
454        // inflate the "N set" summary with fields nobody filled in.
455        if member.is_null() {
456            continue;
457        }
458        let key = custom_field_key(key);
459        if let Some(text) = label(Some(member)) {
460            extra.insert(key, Value::String(text));
461        } else {
462            extra.insert(key, member.clone());
463        }
464    }
465
466    Some(Issue {
467        key: object.get("key")?.as_str()?.to_owned(),
468        summary: object
469            .get("summary")
470            .and_then(Value::as_str)
471            .unwrap_or_default()
472            .to_owned(),
473        status: label(object.get("status")),
474        status_key: key_of(object.get("status")),
475        issue_type: label(object.get("type")),
476        priority: label(object.get("priority")),
477        priority_key: key_of(object.get("priority")),
478        queue: key_label(object.get("queue")),
479        assignee: user(object.get("assignee")),
480        author: user(object.get("createdBy")),
481        created_at: timestamp(object.get("createdAt")),
482        updated_at: timestamp(object.get("updatedAt")),
483        description: object
484            .get("description")
485            .and_then(Value::as_str)
486            .map(ToOwned::to_owned),
487        links: Vec::new(),
488        comment_count: object
489            .get("commentWithoutExternalMessageCount")
490            .and_then(Value::as_u64)
491            .and_then(|count| u32::try_from(count).ok()),
492        extra,
493    })
494}
495
496/// One entry of the changelog.
497#[must_use]
498pub fn change(value: &Value) -> Option<Change> {
499    let fields = value
500        .get("fields")
501        .and_then(Value::as_array)
502        .map(|entries| entries.iter().filter_map(field_change).collect())
503        .unwrap_or_default();
504
505    Some(Change {
506        id: identifier(value.get("id"))?,
507        at: timestamp(value.get("updatedAt")),
508        by: user(value.get("updatedBy")),
509        kind: value
510            .get("type")
511            .and_then(Value::as_str)
512            .unwrap_or("change")
513            .to_owned(),
514        fields,
515    })
516}
517
518fn field_change(value: &Value) -> Option<FieldChange> {
519    // The field's id, not its display name, and for the same reason `dict list`
520    // prints keys: `Статус` is what a Russian organisation calls it, `status` is
521    // what `--set` and `--fields` take. Custom fields arrive prefixed with the
522    // queue id, and the trailing segment is the part a caller can type.
523    let field = value
524        .get("field")
525        .and_then(|field| field.get("id"))
526        .and_then(Value::as_str)
527        .map(custom_field_key)
528        .or_else(|| label(value.get("field")))?;
529
530    Some(FieldChange {
531        field,
532        from: changed_value(value.get("from")),
533        to: changed_value(value.get("to")),
534    })
535}
536
537/// One side of a change, as one line of text.
538///
539/// A field's value can be a reference object, a bare scalar or a list of either
540/// — tags and followers arrive as arrays — and all three have to render as
541/// something a person can compare against the other side. `null` becomes
542/// `None`: on a creation there is no before, and that is not the same as a
543/// field whose value is the word "null".
544fn changed_value(value: Option<&Value>) -> Option<String> {
545    match value? {
546        Value::Null => None,
547        Value::Bool(flag) => Some(flag.to_string()),
548        Value::Number(number) => Some(number.to_string()),
549        Value::Array(entries) => {
550            let joined: Vec<String> = entries
551                .iter()
552                .filter_map(|entry| changed_value(Some(entry)))
553                .collect();
554            (!joined.is_empty()).then(|| joined.join(", "))
555        }
556        // A reference whose only member is a numeric id — board membership
557        // arrives as `[{"id": 1}]` — still has to render as something, or the
558        // line claims nothing changed when something did.
559        other => label(Some(other)).or_else(|| identifier(other.get("id"))),
560    }
561}
562
563/// One entry of an issue type, priority, status or resolution listing.
564///
565/// Without a `key` the entry is useless for the purpose it is listed for — a
566/// write quotes the key — so an entry that has none is dropped rather than
567/// printed as a row nobody can act on.
568#[must_use]
569pub fn dict_entry(value: &Value) -> Option<DictEntry> {
570    Some(DictEntry {
571        key: value.get("key").and_then(Value::as_str)?.to_owned(),
572        name: value
573            .get("name")
574            .and_then(Value::as_str)
575            .unwrap_or_default()
576            .to_owned(),
577        description: value
578            .get("description")
579            .and_then(Value::as_str)
580            .filter(|text| !text.is_empty())
581            .map(ToOwned::to_owned),
582        order: value.get("order").and_then(Value::as_i64),
583        category: value
584            .get("type")
585            .and_then(Value::as_str)
586            .map(ToOwned::to_owned),
587    })
588}
589
590/// One directory record.
591///
592/// `display` is assembled by Tracker from the first and last name and is what
593/// a person is called in every other answer, so it is preferred over
594/// re-assembling it here. An account with neither falls back to its login,
595/// because a blank column reads as a bug.
596#[must_use]
597pub fn person(value: &Value) -> Option<Person> {
598    let login = value.get("login").and_then(Value::as_str)?.to_owned();
599    let display = value
600        .get("display")
601        .and_then(Value::as_str)
602        .filter(|text| !text.trim().is_empty())
603        .unwrap_or(&login)
604        .to_owned();
605
606    Some(Person {
607        uid: identifier(value.get("uid")).unwrap_or_default(),
608        display,
609        email: value
610            .get("email")
611            .and_then(Value::as_str)
612            .filter(|text| !text.is_empty())
613            .map(ToOwned::to_owned),
614        dismissed: value
615            .get("dismissed")
616            .and_then(Value::as_bool)
617            .unwrap_or(false),
618        external: value
619            .get("external")
620            .and_then(Value::as_bool)
621            .unwrap_or(false),
622        login,
623    })
624}
625
626#[cfg(test)]
627#[allow(clippy::expect_used)]
628mod tests {
629    use super::*;
630
631    #[test]
632    fn reference_objects_collapse_to_their_display_name() {
633        let value = serde_json::json!({"display": "In Progress", "key": "inProgress"});
634        assert_eq!(label(Some(&value)).as_deref(), Some("In Progress"));
635    }
636
637    #[test]
638    fn a_reference_without_a_display_falls_back_to_its_key() {
639        let value = serde_json::json!({"key": "PROJ", "id": "7"});
640        assert_eq!(label(Some(&value)).as_deref(), Some("PROJ"));
641    }
642
643    /// Tracker writes offsets as `+0300`; a strict RFC 3339 parser wants `+03:00`.
644    #[test]
645    fn compact_offsets_are_widened_before_parsing() {
646        let value = serde_json::json!("2026-08-27T10:00:00.000+0300");
647        let parsed = timestamp(Some(&value)).expect("parsed");
648        assert_eq!(parsed.to_string(), "2026-08-27T07:00:00Z");
649    }
650
651    #[test]
652    fn utc_timestamps_parse_unchanged() {
653        let value = serde_json::json!("2026-08-27T10:00:00Z");
654        assert!(timestamp(Some(&value)).is_some());
655    }
656
657    /// A date we cannot read costs one line of output. Refusing to show the
658    /// issue would cost the whole command.
659    #[test]
660    fn an_unparseable_date_is_dropped_not_fatal() {
661        let value = serde_json::json!("yesterday");
662        assert!(timestamp(Some(&value)).is_none());
663    }
664
665    #[test]
666    fn unknown_members_become_custom_fields_and_nulls_are_skipped() {
667        let value = serde_json::json!({
668            "key": "PROJ-1",
669            "summary": "s",
670            "storyPoints": 3,
671            "sprint": {"display": "S-12", "id": "9"},
672            "emptyField": null,
673        });
674        let parsed = issue(&value).expect("parsed");
675
676        assert_eq!(parsed.extra.len(), 2);
677        assert_eq!(parsed.extra.get("sprint"), Some(&serde_json::json!("S-12")));
678        assert!(!parsed.extra.contains_key("emptyField"));
679    }
680
681    #[test]
682    fn a_payload_without_a_key_is_not_an_issue() {
683        assert!(issue(&serde_json::json!({"summary": "s"})).is_none());
684    }
685
686    fn subtask_link(direction: &str, key: &str) -> Value {
687        serde_json::json!({
688            "type": {"id": "subtask", "inward": "Is subtask for", "outward": "Is parent task for"},
689            "direction": direction,
690            "object": {"key": key, "display": "some issue"},
691        })
692    }
693
694    /// Tracker answers in the organisation's language. Reading the relationship
695    /// from the localised label turned every link in a Russian organisation into
696    /// the fallback, so the type id decides.
697    #[test]
698    fn a_russian_organisation_still_gets_real_link_types() {
699        let value = serde_json::json!({
700            "type": {"id": "relates", "inward": "Связана", "outward": "Связана"},
701            "direction": "outward",
702            "object": {"key": "LMS-1", "display": "какая-то задача"},
703        });
704
705        assert_eq!(link(&value).expect("link").kind, LinkKind::Relates);
706    }
707
708    /// An unfamiliar link type keeps Tracker's own wording rather than being
709    /// rendered as the word "link".
710    #[test]
711    fn an_unknown_link_type_keeps_what_tracker_called_it() {
712        let value = serde_json::json!({
713            "type": {"id": "somethingNew", "inward": "Blocks release of", "outward": "x"},
714            "direction": "inward",
715            "object": {"key": "PROJ-5"},
716        });
717
718        let parsed = link(&value).expect("link");
719        assert_eq!(parsed.kind, LinkKind::Other);
720        assert_eq!(parsed.relation.as_deref(), Some("blocks release of"));
721    }
722
723    /// Bookkeeping counters are not custom fields; counting them made every
724    /// issue look like it had several.
725    #[test]
726    fn tracker_bookkeeping_is_not_mistaken_for_custom_fields() {
727        let value = serde_json::json!({
728            "key": "PROJ-1",
729            "summary": "s",
730            "commentWithExternalMessageCount": 0,
731            "votes": 3,
732            "followers": [],
733            "storyPoints": 5,
734        });
735
736        let parsed = issue(&value).expect("parsed");
737        assert_eq!(parsed.extra.keys().collect::<Vec<_>>(), ["storyPoints"]);
738    }
739
740    #[test]
741    fn link_direction_decides_parent_from_subtask() {
742        assert_eq!(
743            link(&subtask_link("inward", "PROJ-9")).expect("link").kind,
744            LinkKind::Parent
745        );
746        assert_eq!(
747            link(&subtask_link("outward", "PROJ-12"))
748                .expect("link")
749                .kind,
750            LinkKind::Subtask
751        );
752    }
753
754    /// The labels here are Tracker's own, and they are the way round a real
755    /// organisation sends them: `depends` **inward** is "блокирующая задача",
756    /// the blocking task, so the issue on that end is the one being depended
757    /// on. This was asserted backwards for months, by a test written from the
758    /// same guess as the code, with a fixture that invented the labels the
759    /// other way round.
760    ///
761    /// A live round trip is what settles it, and there is one in the live
762    /// suite: write the link, read it from both ends.
763    #[test]
764    fn the_end_that_depends_is_the_outward_one() {
765        let kind = |direction: &str| {
766            let value = serde_json::json!({
767                "id": 10,
768                "type": {
769                    "id": "depends",
770                    "inward": "блокирующая задача",
771                    "outward": "зависит от",
772                },
773                "direction": direction,
774                "object": {"key": "PROJ-3", "display": "blocker"},
775            });
776            link(&value).expect("link").kind
777        };
778
779        assert_eq!(kind("outward"), LinkKind::Depends);
780        assert_eq!(kind("inward"), LinkKind::IsDependentBy);
781    }
782
783    /// A payload with no id is still a link. Dropping it would hide it, and a
784    /// hidden link is indistinguishable from one that is not there.
785    #[test]
786    fn a_link_without_an_id_is_still_shown() {
787        let value = serde_json::json!({
788            "type": {"id": "relates", "inward": "Связана", "outward": "Связана"},
789            "direction": "outward",
790            "object": {"key": "PROJ-2"},
791        });
792
793        let parsed = link(&value).expect("link");
794        assert!(parsed.id.is_empty());
795        assert_eq!(parsed.key, "PROJ-2");
796    }
797
798    #[test]
799    fn a_queue_renders_as_its_key_not_its_display_name() {
800        let value = serde_json::json!({
801            "key": "PROJ-1",
802            "summary": "s",
803            "queue": {"key": "PROJ", "display": "Product"},
804        });
805        assert_eq!(
806            issue(&value).expect("parsed").queue.as_deref(),
807            Some("PROJ")
808        );
809    }
810
811    /// The line whose whole purpose is to say what to ask for next has to name
812    /// a key `--fields` accepts. Tracker prefixes custom fields with the
813    /// queue's opaque id and takes the trailing segment back.
814    #[test]
815    fn a_custom_field_is_keyed_by_the_name_a_caller_can_type() {
816        let value = serde_json::json!({
817            "key": "PROJ-1",
818            "summary": "x",
819            "603bd9b6cdc7ba0d2f4b1a55--component": "backend",
820            "sprint": "S-12",
821        });
822
823        let parsed = issue(&value).expect("parses");
824
825        assert_eq!(
826            parsed.extra.get("component"),
827            Some(&serde_json::json!("backend"))
828        );
829        assert!(!parsed.extra.keys().any(|key| key.contains("--")));
830        // A field with no prefix is left exactly as it came.
831        assert_eq!(parsed.extra.get("sprint"), Some(&serde_json::json!("S-12")));
832    }
833}