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