git_graph/print/
format.rs

1//! Formatting of commits.
2
3use chrono::{FixedOffset, Local, TimeZone};
4use git2::{Commit, Time};
5use lazy_static::lazy_static;
6use std::fmt::Write;
7use std::str::FromStr;
8use textwrap::Options;
9use yansi::Paint;
10
11/// Commit formatting options.
12#[derive(Ord, PartialOrd, Eq, PartialEq)]
13pub enum CommitFormat {
14    OneLine,
15    Short,
16    Medium,
17    Full,
18    Format(String),
19}
20
21impl FromStr for CommitFormat {
22    type Err = String;
23
24    fn from_str(str: &str) -> Result<Self, Self::Err> {
25        match str {
26            "oneline" | "o" => Ok(CommitFormat::OneLine),
27            "short" | "s" => Ok(CommitFormat::Short),
28            "medium" | "m" => Ok(CommitFormat::Medium),
29            "full" | "f" => Ok(CommitFormat::Full),
30            str => Ok(CommitFormat::Format(str.to_string())),
31        }
32    }
33}
34
35const NEW_LINE: usize = 0;
36const HASH: usize = 1;
37const HASH_ABBREV: usize = 2;
38const PARENT_HASHES: usize = 3;
39const PARENT_HASHES_ABBREV: usize = 4;
40const REFS: usize = 5;
41const SUBJECT: usize = 6;
42const AUTHOR: usize = 7;
43const AUTHOR_EMAIL: usize = 8;
44const AUTHOR_DATE: usize = 9;
45const AUTHOR_DATE_SHORT: usize = 10;
46const AUTHOR_DATE_RELATIVE: usize = 11;
47const COMMITTER: usize = 12;
48const COMMITTER_EMAIL: usize = 13;
49const COMMITTER_DATE: usize = 14;
50const COMMITTER_DATE_SHORT: usize = 15;
51const COMMITTER_DATE_RELATIVE: usize = 16;
52const BODY: usize = 17;
53const BODY_RAW: usize = 18;
54
55const MODE_SPACE: usize = 1;
56const MODE_PLUS: usize = 2;
57const MODE_MINUS: usize = 3;
58
59lazy_static! {
60    pub static ref PLACEHOLDERS: Vec<[String; 4]> = {
61        let base = vec![
62            "n", "H", "h", "P", "p", "d", "s", "an", "ae", "ad", "as", "ar", "cn", "ce", "cd",
63            "cs", "cr", "b", "B",
64        ];
65        base.iter()
66            .map(|b| {
67                [
68                    format!("%{}", b),
69                    format!("% {}", b),
70                    format!("%+{}", b),
71                    format!("%-{}", b),
72                ]
73            })
74            .collect()
75    };
76}
77
78/// Format a commit for `CommitFormat::Format(String)`.
79pub fn format_commit(
80    format: &str,
81    commit: &Commit,
82    branches: String,
83    wrapping: &Option<Options>,
84    hash_color: Option<u8>,
85) -> Result<Vec<String>, String> {
86    let mut replacements = vec![];
87
88    for (idx, arr) in PLACEHOLDERS.iter().enumerate() {
89        let mut curr = 0;
90        loop {
91            let mut found = false;
92            for (mode, str) in arr.iter().enumerate() {
93                if let Some(start) = &format[curr..format.len()].find(str) {
94                    replacements.push((curr + start, str.len(), idx, mode));
95                    curr += start + str.len();
96                    found = true;
97                    break;
98                }
99            }
100            if !found {
101                break;
102            }
103        }
104    }
105
106    replacements.sort_by_key(|p| p.0);
107
108    let mut lines = vec![];
109    let mut out = String::new();
110    if replacements.is_empty() {
111        write!(out, "{}", format).unwrap();
112        add_line(&mut lines, &mut out, wrapping);
113    } else {
114        let mut curr = 0;
115        for (start, len, idx, mode) in replacements {
116            if idx == NEW_LINE {
117                write!(out, "{}", &format[curr..start]).unwrap();
118                add_line(&mut lines, &mut out, wrapping);
119            } else {
120                write!(out, "{}", &format[curr..start]).unwrap();
121                let id = commit.id();
122                match idx {
123                    HASH => {
124                        match mode {
125                            MODE_SPACE => write!(out, " ").unwrap(),
126                            MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
127                            _ => {}
128                        }
129                        if let Some(color) = hash_color {
130                            write!(out, "{}", id.to_string().fixed(color))
131                        } else {
132                            write!(out, "{}", id)
133                        }
134                    }
135                    HASH_ABBREV => {
136                        match mode {
137                            MODE_SPACE => write!(out, " ").unwrap(),
138                            MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
139                            _ => {}
140                        }
141                        if let Some(color) = hash_color {
142                            write!(out, "{}", id.to_string()[..7].fixed(color))
143                        } else {
144                            write!(out, "{}", &id.to_string()[..7])
145                        }
146                    }
147                    PARENT_HASHES => {
148                        match mode {
149                            MODE_SPACE => write!(out, " ").unwrap(),
150                            MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
151                            _ => {}
152                        }
153                        for i in 0..commit.parent_count() {
154                            write!(out, "{}", commit.parent_id(i).unwrap()).unwrap();
155                            if i < commit.parent_count() - 1 {
156                                write!(out, " ").unwrap();
157                            }
158                        }
159                        Ok(())
160                    }
161                    PARENT_HASHES_ABBREV => {
162                        match mode {
163                            MODE_SPACE => write!(out, " ").unwrap(),
164                            MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
165                            _ => {}
166                        }
167                        for i in 0..commit.parent_count() {
168                            write!(
169                                out,
170                                "{}",
171                                &commit
172                                    .parent_id(i)
173                                    .map_err(|err| err.to_string())?
174                                    .to_string()[..7]
175                            )
176                            .unwrap();
177                            if i < commit.parent_count() - 1 {
178                                write!(out, " ").unwrap();
179                            }
180                        }
181                        Ok(())
182                    }
183                    REFS => {
184                        match mode {
185                            MODE_SPACE => {
186                                if !branches.is_empty() {
187                                    write!(out, " ").unwrap()
188                                }
189                            }
190                            MODE_PLUS => {
191                                if !branches.is_empty() {
192                                    add_line(&mut lines, &mut out, wrapping)
193                                }
194                            }
195                            MODE_MINUS => {
196                                if branches.is_empty() {
197                                    out = remove_empty_lines(&mut lines, out)
198                                }
199                            }
200                            _ => {}
201                        }
202                        write!(out, "{}", branches)
203                    }
204                    SUBJECT => {
205                        let summary = commit.summary().unwrap_or("");
206                        match mode {
207                            MODE_SPACE => {
208                                if !summary.is_empty() {
209                                    write!(out, " ").unwrap()
210                                }
211                            }
212                            MODE_PLUS => {
213                                if !summary.is_empty() {
214                                    add_line(&mut lines, &mut out, wrapping)
215                                }
216                            }
217                            MODE_MINUS => {
218                                if summary.is_empty() {
219                                    out = remove_empty_lines(&mut lines, out)
220                                }
221                            }
222                            _ => {}
223                        }
224                        write!(out, "{}", summary)
225                    }
226                    AUTHOR => {
227                        match mode {
228                            MODE_SPACE => write!(out, " ").unwrap(),
229                            MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
230                            _ => {}
231                        }
232                        write!(out, "{}", &commit.author().name().unwrap_or(""))
233                    }
234                    AUTHOR_EMAIL => {
235                        match mode {
236                            MODE_SPACE => write!(out, " ").unwrap(),
237                            MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
238                            _ => {}
239                        }
240                        write!(out, "{}", &commit.author().email().unwrap_or(""))
241                    }
242                    AUTHOR_DATE => {
243                        match mode {
244                            MODE_SPACE => write!(out, " ").unwrap(),
245                            MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
246                            _ => {}
247                        }
248                        write!(
249                            out,
250                            "{}",
251                            format_date(commit.author().when(), "%a %b %e %H:%M:%S %Y %z")
252                        )
253                    }
254                    AUTHOR_DATE_SHORT => {
255                        match mode {
256                            MODE_SPACE => write!(out, " ").unwrap(),
257                            MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
258                            _ => {}
259                        }
260                        write!(out, "{}", format_date(commit.author().when(), "%F"))
261                    }
262                    AUTHOR_DATE_RELATIVE => {
263                        match mode {
264                            MODE_SPACE => write!(out, " ").unwrap(),
265                            MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
266                            _ => {}
267                        }
268                        write!(out, "{}", format_relative_time(commit.author().when()))
269                    }
270                    COMMITTER => {
271                        match mode {
272                            MODE_SPACE => write!(out, " ").unwrap(),
273                            MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
274                            _ => {}
275                        }
276                        write!(out, "{}", &commit.committer().name().unwrap_or(""))
277                    }
278                    COMMITTER_EMAIL => {
279                        match mode {
280                            MODE_SPACE => write!(out, " ").unwrap(),
281                            MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
282                            _ => {}
283                        }
284                        write!(out, "{}", &commit.committer().email().unwrap_or(""))
285                    }
286                    COMMITTER_DATE => {
287                        match mode {
288                            MODE_SPACE => write!(out, " ").unwrap(),
289                            MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
290                            _ => {}
291                        }
292                        write!(
293                            out,
294                            "{}",
295                            format_date(commit.committer().when(), "%a %b %e %H:%M:%S %Y %z")
296                        )
297                    }
298                    COMMITTER_DATE_SHORT => {
299                        match mode {
300                            MODE_SPACE => write!(out, " ").unwrap(),
301                            MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
302                            _ => {}
303                        }
304                        write!(out, "{}", format_date(commit.committer().when(), "%F"))
305                    }
306                    COMMITTER_DATE_RELATIVE => {
307                        match mode {
308                            MODE_SPACE => write!(out, " ").unwrap(),
309                            MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
310                            _ => {}
311                        }
312                        write!(out, "{}", format_relative_time(commit.committer().when()))
313                    }
314                    BODY => {
315                        let message = commit
316                            .message()
317                            .unwrap_or("")
318                            .lines()
319                            .collect::<Vec<&str>>();
320
321                        let num_parts = message.len();
322                        match mode {
323                            MODE_SPACE => {
324                                if num_parts > 2 {
325                                    write!(out, " ").unwrap()
326                                }
327                            }
328                            MODE_PLUS => {
329                                if num_parts > 2 {
330                                    add_line(&mut lines, &mut out, wrapping)
331                                }
332                            }
333                            MODE_MINUS => {
334                                if num_parts <= 2 {
335                                    out = remove_empty_lines(&mut lines, out)
336                                }
337                            }
338                            _ => {}
339                        }
340                        for (cnt, line) in message.iter().enumerate() {
341                            if cnt > 1 && (cnt < num_parts - 1 || !line.is_empty()) {
342                                write!(out, "{}", line).unwrap();
343                                add_line(&mut lines, &mut out, wrapping);
344                            }
345                        }
346                        Ok(())
347                    }
348                    BODY_RAW => {
349                        let message = commit
350                            .message()
351                            .unwrap_or("")
352                            .lines()
353                            .collect::<Vec<&str>>();
354
355                        let num_parts = message.len();
356
357                        match mode {
358                            MODE_SPACE => {
359                                if !message.is_empty() {
360                                    write!(out, " ").unwrap()
361                                }
362                            }
363                            MODE_PLUS => {
364                                if !message.is_empty() {
365                                    add_line(&mut lines, &mut out, wrapping)
366                                }
367                            }
368                            MODE_MINUS => {
369                                if message.is_empty() {
370                                    out = remove_empty_lines(&mut lines, out)
371                                }
372                            }
373                            _ => {}
374                        }
375                        for (cnt, line) in message.iter().enumerate() {
376                            if cnt < num_parts - 1 || !line.is_empty() {
377                                write!(out, "{}", line).unwrap();
378                                add_line(&mut lines, &mut out, wrapping);
379                            }
380                        }
381                        Ok(())
382                    }
383                    x => return Err(format!("No commit field at index {}", x)),
384                }
385                .unwrap();
386            }
387            curr = start + len;
388        }
389        write!(out, "{}", &format[curr..(format.len())]).unwrap();
390        if !out.is_empty() {
391            add_line(&mut lines, &mut out, wrapping);
392        }
393    }
394    Ok(lines)
395}
396
397/// Format a commit for `CommitFormat::OneLine`.
398pub fn format_oneline(
399    commit: &Commit,
400    branches: String,
401    wrapping: &Option<Options>,
402    hash_color: Option<u8>,
403) -> Vec<String> {
404    let mut out = String::new();
405    let id = commit.id();
406    if let Some(color) = hash_color {
407        write!(out, "{}", id.to_string()[..7].fixed(color))
408    } else {
409        write!(out, "{}", &id.to_string()[..7])
410    }
411    .unwrap();
412
413    write!(out, "{} {}", branches, commit.summary().unwrap_or("")).unwrap();
414
415    if let Some(wrap) = wrapping {
416        textwrap::fill(&out, wrap)
417            .lines()
418            .map(|str| str.to_string())
419            .collect()
420    } else {
421        vec![out]
422    }
423}
424
425/// Format a commit for `CommitFormat::Short`, `CommitFormat::Medium` or `CommitFormat::Full`.
426pub fn format(
427    commit: &Commit,
428    branches: String,
429    wrapping: &Option<Options>,
430    hash_color: Option<u8>,
431    format: &CommitFormat,
432) -> Result<Vec<String>, String> {
433    match format {
434        CommitFormat::OneLine => return Ok(format_oneline(commit, branches, wrapping, hash_color)),
435        CommitFormat::Format(format) => {
436            return format_commit(format, commit, branches, wrapping, hash_color)
437        }
438        _ => {}
439    }
440
441    let mut out_vec = vec![];
442    let mut out = String::new();
443
444    let id = commit.id();
445    if let Some(color) = hash_color {
446        write!(out, "commit {}", id.to_string().fixed(color))
447    } else {
448        write!(out, "commit {}", &id)
449    }
450    .map_err(|err| err.to_string())?;
451
452    write!(out, "{}", branches).map_err(|err| err.to_string())?;
453    append_wrapped(&mut out_vec, out, wrapping);
454
455    if commit.parent_count() > 1 {
456        out = String::new();
457        write!(
458            out,
459            "Merge: {} {}",
460            &commit.parent_id(0).unwrap().to_string()[..7],
461            &commit.parent_id(1).unwrap().to_string()[..7]
462        )
463        .map_err(|err| err.to_string())?;
464        append_wrapped(&mut out_vec, out, wrapping);
465    }
466
467    out = String::new();
468    write!(
469        out,
470        "Author: {} <{}>",
471        commit.author().name().unwrap_or(""),
472        commit.author().email().unwrap_or("")
473    )
474    .map_err(|err| err.to_string())?;
475    append_wrapped(&mut out_vec, out, wrapping);
476
477    if format > &CommitFormat::Medium {
478        out = String::new();
479        write!(
480            out,
481            "Commit: {} <{}>",
482            commit.committer().name().unwrap_or(""),
483            commit.committer().email().unwrap_or("")
484        )
485        .map_err(|err| err.to_string())?;
486        append_wrapped(&mut out_vec, out, wrapping);
487    }
488
489    if format > &CommitFormat::Short {
490        out = String::new();
491        write!(
492            out,
493            "Date:   {}",
494            format_date(commit.author().when(), "%a %b %e %H:%M:%S %Y %z")
495        )
496        .map_err(|err| err.to_string())?;
497        append_wrapped(&mut out_vec, out, wrapping);
498    }
499
500    if format == &CommitFormat::Short {
501        out_vec.push("".to_string());
502        append_wrapped(
503            &mut out_vec,
504            format!("    {}", commit.summary().unwrap_or("")),
505            wrapping,
506        );
507        out_vec.push("".to_string());
508    } else {
509        out_vec.push("".to_string());
510        let mut add_line = true;
511        for line in commit.message().unwrap_or("").lines() {
512            if line.is_empty() {
513                out_vec.push(line.to_string());
514            } else {
515                append_wrapped(&mut out_vec, format!("    {}", line), wrapping);
516            }
517            add_line = !line.trim().is_empty();
518        }
519        if add_line {
520            out_vec.push("".to_string());
521        }
522    }
523
524    Ok(out_vec)
525}
526
527pub fn format_date(time: Time, format: &str) -> String {
528    let offset = FixedOffset::east_opt(time.offset_minutes() * 60).expect("Invalid offset minutes");
529    let date = offset
530        .timestamp_opt(time.seconds(), 0)
531        .single()
532        .expect("Invalid timestamp, maybe a fold or gap in local time");
533    date.format(format).to_string()
534}
535
536/// Format a time as a relative time string (e.g., "21 hours ago", "4 days ago")
537pub fn format_relative_time(time: Time) -> String {
538    let offset = FixedOffset::east_opt(time.offset_minutes() * 60).expect("Invalid offset minutes");
539    let commit_time = Local::from_offset(&offset)
540        .timestamp_opt(time.seconds(), 0)
541        .single()
542        .expect("Invalid timestamp");
543    let now = Local::now();
544    let duration = now.signed_duration_since(commit_time);
545
546    let seconds = duration.num_seconds();
547    let minutes = duration.num_minutes();
548    let hours = duration.num_hours();
549    let days = duration.num_hours() / 24;
550    let weeks = days / 7;
551    let months = days / 30;
552    let years = days / 365;
553
554    if seconds < 60 {
555        format!("{} seconds ago", seconds)
556    } else if minutes < 60 {
557        format!("{} minutes ago", minutes)
558    } else if hours < 24 {
559        format!("{} hours ago", hours)
560    } else if days < 7 {
561        format!("{} days ago", days)
562    } else if weeks < 4 {
563        format!("{} weeks ago", weeks)
564    } else if months < 12 {
565        format!("{} months ago", months)
566    } else {
567        format!("{} years ago", years)
568    }
569}
570
571fn append_wrapped(vec: &mut Vec<String>, str: String, wrapping: &Option<Options>) {
572    if str.is_empty() {
573        vec.push(str);
574    } else if let Some(wrap) = wrapping {
575        vec.extend(
576            textwrap::fill(&str, wrap)
577                .lines()
578                .map(|str| str.to_string()),
579        )
580    } else {
581        vec.push(str);
582    }
583}
584
585fn add_line(lines: &mut Vec<String>, line: &mut String, wrapping: &Option<Options>) {
586    let mut temp = String::new();
587    std::mem::swap(&mut temp, line);
588    append_wrapped(lines, temp, wrapping);
589}
590
591fn remove_empty_lines(lines: &mut Vec<String>, mut line: String) -> String {
592    while !lines.is_empty() && lines.last().unwrap().is_empty() {
593        line = lines.remove(lines.len() - 1);
594    }
595    if !lines.is_empty() {
596        line = lines.remove(lines.len() - 1);
597    }
598    line
599}