Skip to main content

ytcli/render/
dict.rs

1//! The organisation's dictionaries: issue types, priorities, statuses,
2//! resolutions.
3//!
4//! The column order carries the point of the command. `KEY` comes first because
5//! it is the value a write has to quote, and `NAME` comes second because it is
6//! the value a person recognises — in a Russian organisation those two read as
7//! `bug` and `Ошибка`, and printing only the second would answer the question
8//! nobody asked.
9
10use std::fmt::Write as _;
11
12use crate::api::models::DictEntry;
13use crate::api::{Dictionary, LinkType};
14use crate::render::Context;
15use crate::render::style::Palette;
16use crate::render::table::{Column, render, tally};
17
18/// One dictionary, under a heading naming which.
19#[must_use]
20pub fn one(kind: Dictionary, entries: &[DictEntry], ctx: &Context) -> String {
21    let paint = ctx.painter();
22    let mut out = String::with_capacity(64 + entries.len() * 48);
23
24    let _ = writeln!(out, "{}", paint.paint(kind.label(), Palette::heading()));
25    out.push_str(&rows(kind, entries, ctx));
26    let _ = writeln!(
27        out,
28        "{}",
29        paint.paint(
30            &format!("shown {} of {}", entries.len(), entries.len()),
31            Palette::label()
32        )
33    );
34    out
35}
36
37/// Several dictionaries in one answer, each under its own heading.
38///
39/// Separated by a blank line so the sections stay tellable apart in a pipe,
40/// where there is no colour to do it.
41#[must_use]
42pub fn many(sections: &[(Dictionary, Vec<DictEntry>)], ctx: &Context) -> String {
43    let mut out = String::new();
44    for (index, (kind, entries)) in sections.iter().enumerate() {
45        if index > 0 {
46            out.push('\n');
47        }
48        out.push_str(&one(*kind, entries, ctx));
49    }
50    out
51}
52
53fn rows(kind: Dictionary, entries: &[DictEntry], ctx: &Context) -> String {
54    // Only statuses have a category, and a column of dashes on the other three
55    // would be three quarters noise.
56    let categorised = kind == Dictionary::Statuses;
57
58    let columns: Vec<Column> = if categorised {
59        vec![
60            Column::whole("KEY", 20, Palette::key()),
61            Column::new("NAME", 32, anstyle::Style::new()),
62            Column::new("CATEGORY", 12, Palette::label()),
63        ]
64    } else {
65        vec![
66            Column::whole("KEY", 20, Palette::key()),
67            Column::new("NAME", 32, anstyle::Style::new()),
68        ]
69    };
70
71    let rows: Vec<Vec<String>> = entries
72        .iter()
73        .map(|entry| {
74            let mut row = vec![entry.key.clone(), entry.name.clone()];
75            if categorised {
76                row.push(entry.category.clone().unwrap_or_else(|| "-".to_owned()));
77            }
78            row
79        })
80        .collect();
81
82    render(&columns, &rows, ctx)
83}
84
85/// The kinds of link, one row per direction you could write.
86///
87/// The point of the command is that there are **two** vocabularies here and
88/// they are not the same list. `WRITE` is what `issue link add` takes; `TYPE` is
89/// the id Tracker files the link under and answers reads with. Writing a type
90/// id — `depends` instead of `depends on` — is refused, and it is the mistake
91/// this tool's own help shipped for several releases.
92///
93/// A direction with no write name is printed with a dash rather than dropped:
94/// `cloners` is a real type, links of it come back from reads, and no
95/// relationship in the write vocabulary produces one. Hiding the row would say
96/// the type does not exist.
97#[must_use]
98pub fn link_types(types: &[LinkType], ctx: &Context) -> String {
99    let columns = [
100        Column::new("WRITE", 20, Palette::key()),
101        Column::new("MEANS", 26, anstyle::Style::new()),
102        Column::whole("TYPE", 12, Palette::label()),
103    ];
104
105    let mut rows = Vec::with_capacity(types.len() * 2);
106    for kind in types {
107        for (outward, label) in [(true, &kind.outward), (false, &kind.inward)] {
108            // A type whose two directions read the same — `relates` — is one
109            // relationship, not two rows saying the same thing twice.
110            if !outward && kind.inward == kind.outward {
111                continue;
112            }
113            rows.push(vec![
114                relationship(&kind.id, outward).unwrap_or("-").to_owned(),
115                label.clone().unwrap_or_else(|| "-".to_owned()),
116                kind.id.clone(),
117            ]);
118        }
119    }
120
121    let mut out = render(&columns, &rows, ctx);
122    out.push_str(&tally(rows.len(), Some(rows.len() as u64), None, ctx));
123    out
124}
125
126/// What `issue link add` takes for one end of one type.
127///
128/// Verified by writing each link into a real Tracker and reading it back: the
129/// pairing is not derivable from the labels, and getting it from the wording
130/// alone is how the direction of `depends` came to be inverted here for months.
131/// The `epic` pair is the one that could not be checked — Tracker refuses the
132/// link unless the issue really is an epic — and is taken from its labels.
133fn relationship(type_id: &str, outward: bool) -> Option<&'static str> {
134    Some(match (type_id, outward) {
135        ("relates", _) => "relates",
136        ("depends", true) => "depends on",
137        ("depends", false) => "is dependent by",
138        ("subtask", true) => "is parent task for",
139        ("subtask", false) => "is subtask for",
140        ("duplicates", true) => "is duplicated by",
141        ("duplicates", false) => "duplicates",
142        ("epic", true) => "has epic",
143        ("epic", false) => "is epic of",
144        _ => return None,
145    })
146}