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#[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
35pub fn oid(oid: impl Into<radicle::git::Oid>) -> Paint<String> {
37 Paint::new(format!("{:.7}", oid.into()))
38}
39
40pub fn parens<D: fmt::Display>(input: Paint<D>) -> Paint<String> {
42 Paint::new(format!("({})", input.item)).with_style(input.style)
43}
44
45pub fn spaced<D: fmt::Display>(input: Paint<D>) -> Paint<String> {
47 Paint::new(format!(" {} ", input.item)).with_style(input.style)
48}
49
50pub fn command<D: fmt::Display>(cmd: D) -> Paint<String> {
52 primary(format!("`{cmd}`"))
53}
54
55#[must_use]
57pub fn cob(id: &ObjectId) -> Paint<String> {
58 Paint::new(format!("{:.7}", id.to_string()))
59}
60
61#[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#[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#[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
86pub 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#[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
154pub struct Identity<'a> {
157 profile: &'a Profile,
158 short: bool,
160 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
212pub 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 #[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
279pub mod html {
281 #[must_use]
283 pub fn commented(s: &str) -> String {
284 format!("<!--\n{s}\n-->")
285 }
286
287 #[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
318pub mod issue {
320 use super::*;
321 use radicle::issue::{CloseReason, State};
322
323 #[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
338pub 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 #[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
364pub mod identity {
366 use super::*;
367 use radicle::cob::identity::State;
368
369 #[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}