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
19pub fn node(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
28pub fn oid(oid: impl Into<radicle::git::Oid>) -> Paint<String> {
30 Paint::new(format!("{:.7}", oid.into()))
31}
32
33pub fn parens<D: fmt::Display>(input: Paint<D>) -> Paint<String> {
35 Paint::new(format!("({})", input.item)).with_style(input.style)
36}
37
38pub fn spaced<D: fmt::Display>(input: Paint<D>) -> Paint<String> {
40 Paint::new(format!(" {} ", input.item)).with_style(input.style)
41}
42
43pub fn command<D: fmt::Display>(cmd: D) -> Paint<String> {
45 primary(format!("`{cmd}`"))
46}
47
48pub fn cob(id: &ObjectId) -> Paint<String> {
50 Paint::new(format!("{:.7}", id.to_string()))
51}
52
53pub fn did(did: &Did) -> Paint<String> {
55 let nid = did.as_key().to_human();
56 Paint::new(format!("{}…{}", &nid[..7], &nid[nid.len() - 7..]))
57}
58
59pub fn visibility(v: &Visibility) -> Paint<&str> {
61 match v {
62 Visibility::Public => term::format::positive("public"),
63 Visibility::Private { .. } => term::format::yellow("private"),
64 }
65}
66
67pub fn policy(p: &Policy) -> Paint<String> {
69 match p {
70 Policy::Allow => term::format::positive(p.to_string()),
71 Policy::Block => term::format::negative(p.to_string()),
72 }
73}
74
75pub fn timestamp(time: impl Into<LocalTime>) -> Paint<String> {
77 let time = time.into();
78 let now = env::local_time();
79 let duration = now - time;
80 let fmt = timeago::Formatter::new();
81
82 Paint::new(fmt.convert(duration.into()))
83}
84
85pub fn bytes(size: usize) -> Paint<String> {
86 const KB: usize = 1024;
87 const MB: usize = 1024usize.pow(2);
88 const GB: usize = 1024usize.pow(3);
89 let size = if size < KB {
90 format!("{size} B")
91 } else if size < MB {
92 format!("{} KiB", size / KB)
93 } else if size < GB {
94 format!("{} MiB", size / MB)
95 } else {
96 format!("{} GiB", size / GB)
97 };
98 Paint::new(size)
99}
100
101pub fn ref_update(update: &RefUpdate) -> Paint<&'static str> {
103 match update {
104 RefUpdate::Updated { .. } => term::format::tertiary("updated"),
105 RefUpdate::Created { .. } => term::format::positive("created"),
106 RefUpdate::Deleted { .. } => term::format::negative("deleted"),
107 RefUpdate::Skipped { .. } => term::format::dim("skipped"),
108 }
109}
110
111pub fn ref_update_verbose(update: &RefUpdate) -> Paint<String> {
112 match update {
113 RefUpdate::Created { name, .. } => format!(
114 "{: <17} {}",
115 term::format::positive("* [new ref]"),
116 term::format::secondary(name),
117 )
118 .into(),
119 RefUpdate::Updated { name, old, new } => format!(
120 "{: <17} {}",
121 format!("{}..{}", term::format::oid(*old), term::format::oid(*new)),
122 term::format::secondary(name),
123 )
124 .into(),
125 RefUpdate::Deleted { name, .. } => format!(
126 "{: <17} {}",
127 term::format::negative("- [deleted]"),
128 term::format::secondary(name),
129 )
130 .into(),
131 RefUpdate::Skipped { name, .. } => format!(
132 "{: <17} {}",
133 term::format::italic("* [skipped]"),
134 term::format::secondary(name)
135 )
136 .into(),
137 }
138}
139
140pub struct Identity<'a> {
143 profile: &'a Profile,
144 short: bool,
146 styled: bool,
149}
150
151impl<'a> Identity<'a> {
152 pub fn new(profile: &'a Profile) -> Self {
153 Self {
154 profile,
155 short: false,
156 styled: false,
157 }
158 }
159
160 pub fn short(mut self) -> Self {
161 self.short = true;
162 self
163 }
164
165 pub fn styled(mut self) -> Self {
166 self.styled = true;
167 self
168 }
169}
170
171impl fmt::Display for Identity<'_> {
172 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173 let nid = self.profile.id();
174 let alias = self.profile.aliases().alias(nid);
175 let node_id = match self.short {
176 true => self::node(nid).to_string(),
177 false => nid.to_human(),
178 };
179
180 if self.styled {
181 write!(f, "{}", term::format::highlight(node_id))?;
182 if let Some(a) = alias {
183 write!(f, " {}", term::format::parens(term::format::dim(a)))?;
184 }
185 } else {
186 write!(f, "{node_id}")?;
187 if let Some(a) = alias {
188 write!(f, " ({a})")?;
189 }
190 }
191 Ok(())
192 }
193}
194
195pub struct Author<'a> {
197 nid: &'a NodeId,
198 alias: Option<Alias>,
199 you: bool,
200}
201
202impl<'a> Author<'a> {
203 pub fn new(nid: &'a NodeId, profile: &Profile) -> Author<'a> {
204 let alias = profile.alias(nid);
205
206 Self {
207 nid,
208 alias,
209 you: nid == profile.id(),
210 }
211 }
212
213 pub fn alias(&self) -> Option<term::Label> {
214 self.alias.as_ref().map(|a| a.to_string().into())
215 }
216
217 pub fn you(&self) -> Option<term::Label> {
218 if self.you {
219 Some(term::format::primary("(you)").dim().italic().into())
220 } else {
221 None
222 }
223 }
224
225 pub fn labels(self) -> (term::Label, term::Label) {
232 let alias = match self.alias.as_ref() {
233 Some(alias) => term::format::primary(alias).into(),
234 None if self.you => term::format::primary(term::format::node(self.nid))
235 .dim()
236 .into(),
237 None => term::Label::blank(),
238 };
239 let author = self.you().unwrap_or_else(|| {
240 term::format::primary(term::format::node(self.nid))
241 .dim()
242 .into()
243 });
244 (alias, author)
245 }
246
247 pub fn line(self) -> Line {
248 let (alias, author) = self.labels();
249 Line::spaced([alias, author])
250 }
251}
252
253pub mod html {
255 pub fn commented(s: &str) -> String {
257 format!("<!--\n{s}\n-->")
258 }
259
260 pub fn strip_comments(s: &str) -> String {
264 let ends_with_newline = s.ends_with('\n');
265 let mut is_comment = false;
266 let mut w = String::new();
267
268 for line in s.lines() {
269 if is_comment {
270 if line.ends_with("-->") {
271 is_comment = false;
272 }
273 continue;
274 } else if line.starts_with("<!--") {
275 is_comment = true;
276 continue;
277 }
278
279 w.push_str(line);
280 w.push('\n');
281 }
282 if !ends_with_newline {
283 w.pop();
284 }
285
286 w.to_string()
287 }
288}
289
290pub mod issue {
292 use super::*;
293 use radicle::issue::{CloseReason, State};
294
295 pub fn state(s: &State) -> term::Paint<String> {
297 match s {
298 State::Open => term::format::positive(s.to_string()),
299 State::Closed {
300 reason: CloseReason::Other,
301 } => term::format::negative(s.to_string()),
302 State::Closed {
303 reason: CloseReason::Solved,
304 } => term::format::secondary(s.to_string()),
305 }
306 }
307}
308
309pub mod patch {
311 use super::*;
312 use radicle::patch::{State, Verdict};
313
314 pub fn verdict(v: Option<Verdict>) -> term::Paint<String> {
315 match v {
316 Some(Verdict::Accept) => term::format::positive("✔".to_string()),
317 Some(Verdict::Reject) => term::format::negative("✗".to_string()),
318 None => term::format::dim("-".to_string()),
319 }
320 }
321
322 pub fn state(s: &State) -> term::Paint<String> {
324 match s {
325 State::Draft { .. } => term::format::dim(s.to_string()),
326 State::Open { .. } => term::format::positive(s.to_string()),
327 State::Archived => term::format::yellow(s.to_string()),
328 State::Merged { .. } => term::format::secondary(s.to_string()),
329 }
330 }
331}
332
333pub mod identity {
335 use super::*;
336 use radicle::cob::identity::State;
337
338 pub fn state(s: &State) -> term::Paint<String> {
340 match s {
341 State::Active { .. } => term::format::tertiary(s.to_string()),
342 State::Accepted { .. } => term::format::positive(s.to_string()),
343 State::Rejected { .. } => term::format::negative(s.to_string()),
344 State::Stale { .. } => term::format::dim(s.to_string()),
345 }
346 }
347}
348
349#[cfg(test)]
350mod test {
351 use super::*;
352 use html::strip_comments;
353
354 #[test]
355 fn test_strip_comments() {
356 let test = "\
357 commit 2\n\
358 \n\
359 <!--\n\
360 Please enter a comment for your patch update. Leaving this\n\
361 blank is also okay.\n\
362 -->";
363 let exp = "\
364 commit 2\n\
365 ";
366
367 let res = strip_comments(test);
368 assert_eq!(exp, res);
369
370 let test = "\
371 commit 2\n\
372 -->";
373 let exp = "\
374 commit 2\n\
375 -->";
376
377 let res = strip_comments(test);
378 assert_eq!(exp, res);
379
380 let test = "\
381 <!--\n\
382 commit 2\n\
383 ";
384 let exp = "";
385
386 let res = strip_comments(test);
387 assert_eq!(exp, res);
388
389 let test = "\
390 commit 2\n\
391 \n\
392 <!--\n\
393 <!--\n\
394 Please enter a comment for your patch update. Leaving this\n\
395 blank is also okay.\n\
396 -->\n\
397 -->";
398 let exp = "\
399 commit 2\n\
400 \n\
401 -->";
402
403 let res = strip_comments(test);
404 assert_eq!(exp, res);
405 }
406
407 #[test]
408 fn test_bytes() {
409 assert_eq!(bytes(1023).to_string(), "1023 B");
410 assert_eq!(bytes(1024).to_string(), "1 KiB");
411 assert_eq!(bytes(1024 * 9).to_string(), "9 KiB");
412 assert_eq!(bytes(1024usize.pow(2)).to_string(), "1 MiB");
413 assert_eq!(bytes(1024usize.pow(2) * 56).to_string(), "56 MiB");
414 assert_eq!(bytes(1024usize.pow(3) * 1024).to_string(), "1024 GiB");
415 }
416}