Skip to main content

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