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