Skip to main content

ytcli/render/
entity.rs

1//! Projects, goals and attachments.
2
3use std::fmt::Write as _;
4
5use crate::api::models::{Attachment, Entity, Page};
6use crate::render::Context;
7use crate::render::style::Palette;
8use crate::render::table::{Column, render, tally};
9
10/// A listing of projects or goals.
11///
12/// The short id leads, because that is the number an issue's `project` field
13/// refers to; the long id follows, because that is what `project get` takes.
14/// Printing only one of them guarantees somebody uses the wrong one.
15#[must_use]
16pub fn entities(page: &Page<Entity>, ctx: &Context) -> String {
17    let columns = [
18        Column::whole("SHORT", 8, Palette::key()),
19        Column::new("ID", 26, Palette::label()),
20        Column::new("STATUS", 14, anstyle::Style::new()),
21        Column::new("SUMMARY", 50, anstyle::Style::new()),
22    ];
23    let rows: Vec<Vec<String>> = page
24        .items
25        .iter()
26        .map(|entity| {
27            vec![
28                entity
29                    .short_id
30                    .map_or_else(|| "-".to_owned(), |id| id.to_string()),
31                entity.id.clone(),
32                entity.status.as_deref().unwrap_or("-").to_owned(),
33                entity.summary.clone(),
34            ]
35        })
36        .collect();
37
38    let mut out = render(&columns, &rows, ctx);
39    out.push_str(&tally(
40        page.items.len(),
41        page.total,
42        page.has_more().then_some(page.page + 1),
43        ctx,
44    ));
45    out
46}
47
48/// One project or goal.
49#[must_use]
50pub fn entity(entity: &Entity, ctx: &Context) -> String {
51    let mut out = String::with_capacity(320);
52    let paint = ctx.painter();
53    let label = |text: &str| paint.paint(text, Palette::label());
54
55    let _ = writeln!(
56        out,
57        "{}  {}",
58        paint.paint(&entity.id, Palette::key()),
59        entity.summary
60    );
61    let _ = writeln!(
62        out,
63        "{} {}   {} {}   {} {}",
64        label("short id:"),
65        paint.paint(
66            &entity
67                .short_id
68                .map_or_else(|| "-".to_owned(), |id| id.to_string()),
69            Palette::key()
70        ),
71        label("status:"),
72        entity.status.as_deref().unwrap_or("-"),
73        label("lead:"),
74        entity
75            .lead
76            .as_ref()
77            .and_then(|lead| lead.login.as_deref().or(lead.display.as_deref()))
78            .unwrap_or("-"),
79    );
80    let _ = writeln!(
81        out,
82        "{} {}   {} {}",
83        label("start:"),
84        entity.start.as_deref().unwrap_or("-"),
85        label("end:"),
86        entity.end.as_deref().unwrap_or("-"),
87    );
88    if let Some(parent) = entity.parent.as_deref() {
89        let _ = writeln!(out, "{} {}", label("in portfolio:"), parent);
90    }
91
92    if let Some(description) = entity.description.as_deref().filter(|d| !d.is_empty()) {
93        let (body, withheld) = crate::render::untrusted::head(description, ctx.description_lines);
94        crate::render::text::quoted_block(
95            &mut out,
96            &format!("{}/description", entity.id),
97            &body,
98            withheld,
99            ctx,
100        );
101    }
102
103    out
104}
105
106/// What a portfolio contains.
107///
108/// The type column is the point of this listing rather than decoration: a
109/// portfolio holds portfolios as well as projects, and the id alone does not say
110/// which `get` reads it back.
111#[must_use]
112pub fn contents(page: &Page<Entity>, ctx: &Context) -> String {
113    let columns = [
114        Column::whole("SHORT", 8, Palette::key()),
115        Column::new("TYPE", 10, Palette::label()),
116        Column::new("ID", 26, Palette::label()),
117        Column::new("STATUS", 14, anstyle::Style::new()),
118        Column::new("SUMMARY", 40, anstyle::Style::new()),
119    ];
120    let rows: Vec<Vec<String>> = page
121        .items
122        .iter()
123        .map(|entity| {
124            vec![
125                entity
126                    .short_id
127                    .map_or_else(|| "-".to_owned(), |id| id.to_string()),
128                entity.entity_type.as_deref().unwrap_or("-").to_owned(),
129                entity.id.clone(),
130                entity.status.as_deref().unwrap_or("-").to_owned(),
131                entity.summary.clone(),
132            ]
133        })
134        .collect();
135
136    let mut out = render(&columns, &rows, ctx);
137    out.push_str(&tally(
138        page.items.len(),
139        page.total,
140        page.has_more().then_some(page.page + 1),
141        ctx,
142    ));
143    out
144}
145
146/// Attachments of an issue.
147///
148/// The filename was chosen by whoever uploaded it, so it does not get the
149/// styling our own output uses: a name carries as much text as a comment can.
150#[must_use]
151pub fn attachments(key: &str, attachments: &[Attachment], ctx: &Context) -> String {
152    let columns = [
153        Column::whole("ID", 14, Palette::key()),
154        Column::whole("SIZE", 10, anstyle::Style::new()),
155        Column::new("TYPE", 18, anstyle::Style::new()),
156        Column::whole("NAME", 40, Palette::untrusted()),
157    ];
158    let rows: Vec<Vec<String>> = attachments
159        .iter()
160        .map(|attachment| {
161            vec![
162                attachment.id.clone(),
163                attachment.size.map_or_else(|| "-".to_owned(), human_size),
164                attachment.mimetype.as_deref().unwrap_or("-").to_owned(),
165                attachment.name.clone(),
166            ]
167        })
168        .collect();
169
170    let mut out = render(&columns, &rows, ctx);
171    let paint = ctx.painter();
172    let _ = writeln!(
173        out,
174        "{}",
175        paint.paint(
176            &format!(
177                "shown {} of {} for {key}",
178                attachments.len(),
179                attachments.len()
180            ),
181            Palette::label()
182        )
183    );
184    out
185}
186
187/// A byte count in the units a person reads.
188#[must_use]
189pub fn human_size(bytes: u64) -> String {
190    const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
191    // Precision beyond 2^52 bytes is not a concern for a file size, and the
192    // value is only ever rendered to one decimal place.
193    #[allow(clippy::cast_precision_loss)]
194    let mut size = bytes as f64;
195    let mut unit = 0;
196    while size >= 1024.0 && unit < UNITS.len() - 1 {
197        size /= 1024.0;
198        unit += 1;
199    }
200    if unit == 0 {
201        format!("{bytes} B")
202    } else {
203        format!("{size:.1} {}", UNITS[unit])
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn sizes_are_readable_without_losing_small_ones() {
213        assert_eq!(human_size(512), "512 B");
214        assert_eq!(human_size(2048), "2.0 KB");
215        assert_eq!(human_size(5 * 1024 * 1024), "5.0 MB");
216    }
217}