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            crate::render::untrusted::Author::Tracker,
98            &body,
99            withheld,
100            ctx,
101        );
102    }
103
104    out
105}
106
107/// What a portfolio contains.
108///
109/// The type column is the point of this listing rather than decoration: a
110/// portfolio holds portfolios as well as projects, and the id alone does not say
111/// which `get` reads it back.
112#[must_use]
113pub fn contents(page: &Page<Entity>, ctx: &Context) -> String {
114    let columns = [
115        Column::whole("SHORT", 8, Palette::key()),
116        Column::new("TYPE", 10, Palette::label()),
117        Column::new("ID", 26, Palette::label()),
118        Column::new("STATUS", 14, anstyle::Style::new()),
119        Column::new("SUMMARY", 40, anstyle::Style::new()),
120    ];
121    let rows: Vec<Vec<String>> = page
122        .items
123        .iter()
124        .map(|entity| {
125            vec![
126                entity
127                    .short_id
128                    .map_or_else(|| "-".to_owned(), |id| id.to_string()),
129                entity.entity_type.as_deref().unwrap_or("-").to_owned(),
130                entity.id.clone(),
131                entity.status.as_deref().unwrap_or("-").to_owned(),
132                entity.summary.clone(),
133            ]
134        })
135        .collect();
136
137    let mut out = render(&columns, &rows, ctx);
138    out.push_str(&tally(
139        page.items.len(),
140        page.total,
141        page.has_more().then_some(page.page + 1),
142        ctx,
143    ));
144    out
145}
146
147/// Attachments of an issue.
148///
149/// The filename was chosen by whoever uploaded it, so it does not get the
150/// styling our own output uses: a name carries as much text as a comment can.
151#[must_use]
152pub fn attachments(key: &str, attachments: &[Attachment], ctx: &Context) -> String {
153    let columns = [
154        Column::whole("ID", 14, Palette::key()),
155        Column::whole("SIZE", 10, anstyle::Style::new()),
156        Column::new("TYPE", 18, anstyle::Style::new()),
157        Column::whole("NAME", 40, Palette::untrusted()),
158    ];
159    let rows: Vec<Vec<String>> = attachments
160        .iter()
161        .map(|attachment| {
162            vec![
163                attachment.id.clone(),
164                attachment.size.map_or_else(|| "-".to_owned(), human_size),
165                attachment.mimetype.as_deref().unwrap_or("-").to_owned(),
166                attachment.name.clone(),
167            ]
168        })
169        .collect();
170
171    let mut out = render(&columns, &rows, ctx);
172    let paint = ctx.painter();
173    let _ = writeln!(
174        out,
175        "{}",
176        paint.paint(
177            &format!(
178                "shown {} of {} for {key}",
179                attachments.len(),
180                attachments.len()
181            ),
182            Palette::label()
183        )
184    );
185    out
186}
187
188/// A byte count in the units a person reads.
189#[must_use]
190pub fn human_size(bytes: u64) -> String {
191    const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
192    // Precision beyond 2^52 bytes is not a concern for a file size, and the
193    // value is only ever rendered to one decimal place.
194    #[allow(clippy::cast_precision_loss)]
195    let mut size = bytes as f64;
196    let mut unit = 0;
197    while size >= 1024.0 && unit < UNITS.len() - 1 {
198        size /= 1024.0;
199        unit += 1;
200    }
201    if unit == 0 {
202        format!("{bytes} B")
203    } else {
204        format!("{size:.1} {}", UNITS[unit])
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn sizes_are_readable_without_losing_small_ones() {
214        assert_eq!(human_size(512), "512 B");
215        assert_eq!(human_size(2048), "2.0 KB");
216        assert_eq!(human_size(5 * 1024 * 1024), "5.0 MB");
217    }
218}