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