radicle_cli/terminal/
format.rs

1use std::fmt;
2
3use localtime::LocalTime;
4
5pub use radicle_term::format::*;
6pub use radicle_term::{style, Paint};
7
8use radicle::cob::ObjectId;
9use radicle::identity::Visibility;
10use radicle::node::policy::Policy;
11use radicle::node::{Alias, AliasStore, NodeId};
12use radicle::prelude::Did;
13use radicle::profile::{env, Profile};
14use radicle::storage::RefUpdate;
15use radicle_term::element::Line;
16
17use crate::terminal as term;
18
19/// Format a node id to be more compact.
20pub fn node(node: &NodeId) -> Paint<String> {
21    let node = node.to_human();
22    let start = node.chars().take(7).collect::<String>();
23    let end = node.chars().skip(node.len() - 7).collect::<String>();
24
25    Paint::new(format!("{start}…{end}"))
26}
27
28/// Format a git Oid.
29pub fn oid(oid: impl Into<radicle::git::Oid>) -> Paint<String> {
30    Paint::new(format!("{:.7}", oid.into()))
31}
32
33/// Wrap parenthesis around styled input, eg. `"input"` -> `"(input)"`.
34pub fn parens<D: fmt::Display>(input: Paint<D>) -> Paint<String> {
35    Paint::new(format!("({})", input.item)).with_style(input.style)
36}
37
38/// Wrap spaces around styled input, eg. `"input"` -> `" input "`.
39pub fn spaced<D: fmt::Display>(input: Paint<D>) -> Paint<String> {
40    Paint::new(format!(" {} ", input.item)).with_style(input.style)
41}
42
43/// Format a command suggestion, eg. `rad init`.
44pub fn command<D: fmt::Display>(cmd: D) -> Paint<String> {
45    primary(format!("`{cmd}`"))
46}
47
48/// Format a COB id.
49pub fn cob(id: &ObjectId) -> Paint<String> {
50    Paint::new(format!("{:.7}", id.to_string()))
51}
52
53/// Format a DID.
54pub fn did(did: &Did) -> Paint<String> {
55    let nid = did.as_key().to_human();
56    Paint::new(format!("{}…{}", &nid[..7], &nid[nid.len() - 7..]))
57}
58
59/// Format a Visibility.
60pub fn visibility(v: &Visibility) -> Paint<&str> {
61    match v {
62        Visibility::Public => term::format::positive("public"),
63        Visibility::Private { .. } => term::format::yellow("private"),
64    }
65}
66
67/// Format a policy.
68pub fn policy(p: &Policy) -> Paint<String> {
69    match p {
70        Policy::Allow => term::format::positive(p.to_string()),
71        Policy::Block => term::format::negative(p.to_string()),
72    }
73}
74
75/// Format a timestamp.
76pub fn timestamp(time: impl Into<LocalTime>) -> Paint<String> {
77    let time = time.into();
78    let now = env::local_time();
79    let duration = now - time;
80    let fmt = timeago::Formatter::new();
81
82    Paint::new(fmt.convert(duration.into()))
83}
84
85pub fn bytes(size: usize) -> Paint<String> {
86    const KB: usize = 1024;
87    const MB: usize = 1024usize.pow(2);
88    const GB: usize = 1024usize.pow(3);
89    let size = if size < KB {
90        format!("{size} B")
91    } else if size < MB {
92        format!("{} KiB", size / KB)
93    } else if size < GB {
94        format!("{} MiB", size / MB)
95    } else {
96        format!("{} GiB", size / GB)
97    };
98    Paint::new(size)
99}
100
101/// Format a ref update.
102pub fn ref_update(update: &RefUpdate) -> Paint<&'static str> {
103    match update {
104        RefUpdate::Updated { .. } => term::format::tertiary("updated"),
105        RefUpdate::Created { .. } => term::format::positive("created"),
106        RefUpdate::Deleted { .. } => term::format::negative("deleted"),
107        RefUpdate::Skipped { .. } => term::format::dim("skipped"),
108    }
109}
110
111pub fn ref_update_verbose(update: &RefUpdate) -> Paint<String> {
112    match update {
113        RefUpdate::Created { name, .. } => format!(
114            "{: <17} {}",
115            term::format::positive("* [new ref]"),
116            term::format::secondary(name),
117        )
118        .into(),
119        RefUpdate::Updated { name, old, new } => format!(
120            "{: <17} {}",
121            format!("{}..{}", term::format::oid(*old), term::format::oid(*new)),
122            term::format::secondary(name),
123        )
124        .into(),
125        RefUpdate::Deleted { name, .. } => format!(
126            "{: <17} {}",
127            term::format::negative("- [deleted]"),
128            term::format::secondary(name),
129        )
130        .into(),
131        RefUpdate::Skipped { name, .. } => format!(
132            "{: <17} {}",
133            term::format::italic("* [skipped]"),
134            term::format::secondary(name)
135        )
136        .into(),
137    }
138}
139
140/// Identity formatter that takes a profile and displays it as
141/// `<node-id> (<username>)` depending on the configuration.
142pub struct Identity<'a> {
143    profile: &'a Profile,
144    /// If true, node id is printed in its compact form.
145    short: bool,
146    /// If true, node id and username are printed using the terminal's
147    /// styled formatters.
148    styled: bool,
149}
150
151impl<'a> Identity<'a> {
152    pub fn new(profile: &'a Profile) -> Self {
153        Self {
154            profile,
155            short: false,
156            styled: false,
157        }
158    }
159
160    pub fn short(mut self) -> Self {
161        self.short = true;
162        self
163    }
164
165    pub fn styled(mut self) -> Self {
166        self.styled = true;
167        self
168    }
169}
170
171impl fmt::Display for Identity<'_> {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        let nid = self.profile.id();
174        let alias = self.profile.aliases().alias(nid);
175        let node_id = match self.short {
176            true => self::node(nid).to_string(),
177            false => nid.to_human(),
178        };
179
180        if self.styled {
181            write!(f, "{}", term::format::highlight(node_id))?;
182            if let Some(a) = alias {
183                write!(f, " {}", term::format::parens(term::format::dim(a)))?;
184            }
185        } else {
186            write!(f, "{node_id}")?;
187            if let Some(a) = alias {
188                write!(f, " ({a})")?;
189            }
190        }
191        Ok(())
192    }
193}
194
195/// This enum renders (nid, alias) in terminal depending on user variant.
196pub struct Author<'a> {
197    nid: &'a NodeId,
198    alias: Option<Alias>,
199    you: bool,
200}
201
202impl<'a> Author<'a> {
203    pub fn new(nid: &'a NodeId, profile: &Profile) -> Author<'a> {
204        let alias = profile.alias(nid);
205
206        Self {
207            nid,
208            alias,
209            you: nid == profile.id(),
210        }
211    }
212
213    pub fn alias(&self) -> Option<term::Label> {
214        self.alias.as_ref().map(|a| a.to_string().into())
215    }
216
217    pub fn you(&self) -> Option<term::Label> {
218        if self.you {
219            Some(term::format::primary("(you)").dim().italic().into())
220        } else {
221            None
222        }
223    }
224
225    /// Get the labels of the `Author`. The labels can take the following forms:
226    ///
227    ///   * `(<alias>, (you))` -- the `Author` is the local peer and has an alias
228    ///   * `(<did>, (you))` -- the `Author` is the local peer and has no alias
229    ///   * `(<alias>, <did>)` -- the `Author` is another peer and has an alias
230    ///   * `(<blank>, <did>)` -- the `Author` is another peer and has no alias
231    pub fn labels(self) -> (term::Label, term::Label) {
232        let alias = match self.alias.as_ref() {
233            Some(alias) => term::format::primary(alias).into(),
234            None if self.you => term::format::primary(term::format::node(self.nid))
235                .dim()
236                .into(),
237            None => term::Label::blank(),
238        };
239        let author = self.you().unwrap_or_else(|| {
240            term::format::primary(term::format::node(self.nid))
241                .dim()
242                .into()
243        });
244        (alias, author)
245    }
246
247    pub fn line(self) -> Line {
248        let (alias, author) = self.labels();
249        Line::spaced([alias, author])
250    }
251}
252
253/// HTML-related formatting.
254pub mod html {
255    /// Comment a string with HTML comments.
256    pub fn commented(s: &str) -> String {
257        format!("<!--\n{s}\n-->")
258    }
259
260    /// Remove html style comments from a string.
261    ///
262    /// The HTML comments must start at the beginning of a line and stop at the end.
263    pub fn strip_comments(s: &str) -> String {
264        let ends_with_newline = s.ends_with('\n');
265        let mut is_comment = false;
266        let mut w = String::new();
267
268        for line in s.lines() {
269            if is_comment {
270                if line.ends_with("-->") {
271                    is_comment = false;
272                }
273                continue;
274            } else if line.starts_with("<!--") {
275                is_comment = true;
276                continue;
277            }
278
279            w.push_str(line);
280            w.push('\n');
281        }
282        if !ends_with_newline {
283            w.pop();
284        }
285
286        w.to_string()
287    }
288}
289
290/// Issue formatting
291pub mod issue {
292    use super::*;
293    use radicle::issue::{CloseReason, State};
294
295    /// Format issue state.
296    pub fn state(s: &State) -> term::Paint<String> {
297        match s {
298            State::Open => term::format::positive(s.to_string()),
299            State::Closed {
300                reason: CloseReason::Other,
301            } => term::format::negative(s.to_string()),
302            State::Closed {
303                reason: CloseReason::Solved,
304            } => term::format::secondary(s.to_string()),
305        }
306    }
307}
308
309/// Patch formatting
310pub mod patch {
311    use super::*;
312    use radicle::patch::{State, Verdict};
313
314    pub fn verdict(v: Option<Verdict>) -> term::Paint<String> {
315        match v {
316            Some(Verdict::Accept) => term::format::positive("✔".to_string()),
317            Some(Verdict::Reject) => term::format::negative("✗".to_string()),
318            None => term::format::dim("-".to_string()),
319        }
320    }
321
322    /// Format patch state.
323    pub fn state(s: &State) -> term::Paint<String> {
324        match s {
325            State::Draft { .. } => term::format::dim(s.to_string()),
326            State::Open { .. } => term::format::positive(s.to_string()),
327            State::Archived => term::format::yellow(s.to_string()),
328            State::Merged { .. } => term::format::secondary(s.to_string()),
329        }
330    }
331}
332
333/// Identity formatting
334pub mod identity {
335    use super::*;
336    use radicle::cob::identity::State;
337
338    /// Format identity revision state.
339    pub fn state(s: &State) -> term::Paint<String> {
340        match s {
341            State::Active { .. } => term::format::tertiary(s.to_string()),
342            State::Accepted { .. } => term::format::positive(s.to_string()),
343            State::Rejected { .. } => term::format::negative(s.to_string()),
344            State::Stale { .. } => term::format::dim(s.to_string()),
345        }
346    }
347}
348
349#[cfg(test)]
350mod test {
351    use super::*;
352    use html::strip_comments;
353
354    #[test]
355    fn test_strip_comments() {
356        let test = "\
357        commit 2\n\
358        \n\
359        <!--\n\
360        Please enter a comment for your patch update. Leaving this\n\
361        blank is also okay.\n\
362        -->";
363        let exp = "\
364        commit 2\n\
365        ";
366
367        let res = strip_comments(test);
368        assert_eq!(exp, res);
369
370        let test = "\
371        commit 2\n\
372        -->";
373        let exp = "\
374        commit 2\n\
375        -->";
376
377        let res = strip_comments(test);
378        assert_eq!(exp, res);
379
380        let test = "\
381        <!--\n\
382        commit 2\n\
383        ";
384        let exp = "";
385
386        let res = strip_comments(test);
387        assert_eq!(exp, res);
388
389        let test = "\
390        commit 2\n\
391        \n\
392        <!--\n\
393        <!--\n\
394        Please enter a comment for your patch update. Leaving this\n\
395        blank is also okay.\n\
396        -->\n\
397        -->";
398        let exp = "\
399        commit 2\n\
400        \n\
401        -->";
402
403        let res = strip_comments(test);
404        assert_eq!(exp, res);
405    }
406
407    #[test]
408    fn test_bytes() {
409        assert_eq!(bytes(1023).to_string(), "1023 B");
410        assert_eq!(bytes(1024).to_string(), "1 KiB");
411        assert_eq!(bytes(1024 * 9).to_string(), "9 KiB");
412        assert_eq!(bytes(1024usize.pow(2)).to_string(), "1 MiB");
413        assert_eq!(bytes(1024usize.pow(2) * 56).to_string(), "56 MiB");
414        assert_eq!(bytes(1024usize.pow(3) * 1024).to_string(), "1024 GiB");
415    }
416}