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#[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#[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
57pub fn oid(oid: impl Into<radicle::git::Oid>) -> Paint<String> {
59 Paint::new(format!("{:.7}", oid.into()))
60}
61
62pub fn parens<D: fmt::Display>(input: Paint<D>) -> Paint<String> {
64 Paint::new(format!("({})", input.item)).with_style(input.style)
65}
66
67pub fn spaced<D: fmt::Display>(input: Paint<D>) -> Paint<String> {
69 Paint::new(format!(" {} ", input.item)).with_style(input.style)
70}
71
72pub fn command<D: fmt::Display>(cmd: D) -> Paint<String> {
74 primary(format!("`{cmd}`"))
75}
76
77#[must_use]
79pub fn cob(id: &ObjectId) -> Paint<String> {
80 Paint::new(format!("{:.7}", id.to_string()))
81}
82
83#[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#[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#[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
108pub 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#[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
176pub struct Identity<'a> {
179 profile: &'a Profile,
180 short: bool,
182 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
234pub 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 #[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
301pub mod html {
303 #[must_use]
305 pub fn commented(s: &str) -> String {
306 format!("<!--\n{s}\n-->")
307 }
308
309 #[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
340pub mod issue {
342 use super::*;
343 use radicle::issue::{CloseReason, State};
344
345 #[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
360pub 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 #[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
386pub mod identity {
388 use super::*;
389 use radicle::cob::identity::State;
390
391 #[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}