Skip to main content

sley_ref_filter/
lib.rs

1//! Shared ref-filter formatting primitives.
2//!
3//! Git reuses the same identity/date/refname formatting language across
4//! `for-each-ref`, `branch`, `tag`, `log`, `show`, `stash`, and status output.
5//! This crate owns those semantic primitives so the CLI can remain an entry
6//! point instead of a home for every command's formatting state.
7
8use sley_core::{DateMode, GitError, ObjectId, Result};
9use sley_strbuf_expand::{
10    AtomTable, ExpandFormat, ExpandSegment, MagicPrefix, PaddingAlign, PaddingSpec,
11};
12use std::io::Write;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ForEachRefFormat {
16    inner: ExpandFormat<ForEachRefAtom>,
17    segments: Vec<ForEachRefFormatSegment>,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum ForEachRefFormatSegment {
22    Literal(Vec<u8>),
23    Atom(ForEachRefAtom),
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum ForEachRefAtom {
28    Raw(String),
29    Color(String),
30    RefName {
31        source: ForEachRefNameSource,
32        format: ForEachRefNameFormat,
33    },
34    ObjectName {
35        peeled: bool,
36        abbrev: Option<usize>,
37    },
38    Identity {
39        peeled: bool,
40        role: ForEachRefAtomIdentityRole,
41        part: ForEachRefAtomIdentityPart,
42    },
43    ContentsLines {
44        peeled: bool,
45        count: usize,
46    },
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ForEachRefNameSource {
51    Ref,
52    Upstream,
53    Push,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum ForEachRefNameFormat {
58    Full,
59    Short,
60    Strip(ForEachRefStrip),
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct ForEachRefStrip {
65    pub direction: ForEachRefStripDirection,
66    pub count: isize,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum ForEachRefStripDirection {
71    Left,
72    Right,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum ForEachRefAtomIdentityRole {
77    Author,
78    Committer,
79    Tagger,
80    Creator,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum ForEachRefAtomIdentityPart {
85    Full,
86    Name,
87    Email(ForEachRefEmailMode),
88    Date(DateMode),
89    DateRaw,
90}
91
92impl ForEachRefAtom {
93    fn parse(value: &str) -> Result<Self> {
94        // git's parse_ref_filter_atom: an empty sub-argument list is treated as
95        // NULL, i.e. `%(atom:)` is equivalent to `%(atom)`. The arg is whatever
96        // follows the FIRST colon, so only a trailing colon at that position is
97        // dropped (e.g. `refname:` -> `refname`).
98        let value = match value.split_once(':') {
99            Some((name, "")) => name,
100            _ => value,
101        };
102        if let Some(color) = value.strip_prefix("color:") {
103            return Ok(Self::Color(color.to_string()));
104        }
105        if let Some(atom) = parse_for_each_ref_refname_atom(value)? {
106            return Ok(atom);
107        }
108        if let Some(atom) = parse_for_each_ref_objectname_atom(value)? {
109            return Ok(atom);
110        }
111        if let Some(atom) = parse_for_each_ref_identity_atom(value) {
112            return Ok(atom);
113        }
114        if let Some(count) = value.strip_prefix("contents:lines=") {
115            return Ok(Self::ContentsLines {
116                peeled: false,
117                count: parse_for_each_ref_contents_lines_count(count)?,
118            });
119        }
120        if let Some(count) = value.strip_prefix("*contents:lines=") {
121            return Ok(Self::ContentsLines {
122                peeled: true,
123                count: parse_for_each_ref_contents_lines_count(count)?,
124            });
125        }
126        Ok(Self::Raw(value.to_string()))
127    }
128}
129
130struct ForEachRefAtomTable;
131
132impl AtomTable for ForEachRefAtomTable {
133    type Atom = ForEachRefAtom;
134
135    fn parse_atom(&self, value: &str) -> Result<Self::Atom> {
136        ForEachRefAtom::parse(value)
137    }
138}
139
140fn parse_for_each_ref_refname_atom(value: &str) -> Result<Option<ForEachRefAtom>> {
141    for (prefix, source) in [
142        ("refname", ForEachRefNameSource::Ref),
143        ("upstream", ForEachRefNameSource::Upstream),
144        ("push", ForEachRefNameSource::Push),
145    ] {
146        if value == prefix {
147            return Ok(Some(ForEachRefAtom::RefName {
148                source,
149                format: ForEachRefNameFormat::Full,
150            }));
151        }
152        let Some(modifier) = value
153            .strip_prefix(prefix)
154            .and_then(|value| value.strip_prefix(':'))
155        else {
156            continue;
157        };
158        let format = if modifier == "short" {
159            ForEachRefNameFormat::Short
160        } else if let Some(count) = modifier
161            .strip_prefix("lstrip=")
162            .or_else(|| modifier.strip_prefix("strip="))
163        {
164            ForEachRefNameFormat::Strip(ForEachRefStrip {
165                direction: ForEachRefStripDirection::Left,
166                count: parse_for_each_ref_strip_count(count)?,
167            })
168        } else if let Some(count) = modifier.strip_prefix("rstrip=") {
169            ForEachRefNameFormat::Strip(ForEachRefStrip {
170                direction: ForEachRefStripDirection::Right,
171                count: parse_for_each_ref_strip_count(count)?,
172            })
173        } else if prefix == "refname" {
174            // git's refname_atom_parser rejects unknown args outright (the
175            // upstream/push variants accept extra modifiers handled later, so
176            // only `refname` is strict here).
177            eprintln!("fatal: unrecognized %({prefix}) argument: {modifier}");
178            return Err(GitError::Exit(128));
179        } else {
180            continue;
181        };
182        return Ok(Some(ForEachRefAtom::RefName { source, format }));
183    }
184    Ok(None)
185}
186
187fn parse_for_each_ref_objectname_atom(value: &str) -> Result<Option<ForEachRefAtom>> {
188    for (prefix, peeled) in [("objectname", false), ("*objectname", true)] {
189        if value == prefix {
190            return Ok(Some(ForEachRefAtom::ObjectName {
191                peeled,
192                abbrev: None,
193            }));
194        }
195        if value.strip_prefix(prefix) == Some(":short") {
196            return Ok(Some(ForEachRefAtom::ObjectName {
197                peeled,
198                abbrev: Some(0),
199            }));
200        }
201        if let Some(width) = value
202            .strip_prefix(prefix)
203            .and_then(|value| value.strip_prefix(":short="))
204        {
205            return Ok(Some(ForEachRefAtom::ObjectName {
206                peeled,
207                abbrev: Some(parse_for_each_ref_abbrev_width(width)?),
208            }));
209        }
210    }
211    Ok(None)
212}
213
214fn parse_for_each_ref_identity_atom(value: &str) -> Option<ForEachRefAtom> {
215    let (value, peeled) = value
216        .strip_prefix('*')
217        .map(|value| (value, true))
218        .unwrap_or((value, false));
219    let (atom, has_modifier) = value
220        .split_once(':')
221        .map_or((value, false), |(atom, _)| (atom, true));
222    // `name` and the bare-identity atoms take no modifier in this typed path;
223    // anything with a `:` (e.g. `authorname:mailmap`, `author:foo`) falls through
224    // to the string/Raw renderer which owns the full option grammar + errors.
225    let plain = |part: ForEachRefAtomIdentityPart| if has_modifier { None } else { Some(part) };
226    let (role, part) = match atom {
227        "author" => (
228            ForEachRefAtomIdentityRole::Author,
229            plain(ForEachRefAtomIdentityPart::Full)?,
230        ),
231        "authorname" => (
232            ForEachRefAtomIdentityRole::Author,
233            plain(ForEachRefAtomIdentityPart::Name)?,
234        ),
235        "committer" => (
236            ForEachRefAtomIdentityRole::Committer,
237            plain(ForEachRefAtomIdentityPart::Full)?,
238        ),
239        "committername" => (
240            ForEachRefAtomIdentityRole::Committer,
241            plain(ForEachRefAtomIdentityPart::Name)?,
242        ),
243        "tagger" => (
244            ForEachRefAtomIdentityRole::Tagger,
245            plain(ForEachRefAtomIdentityPart::Full)?,
246        ),
247        "taggername" => (
248            ForEachRefAtomIdentityRole::Tagger,
249            plain(ForEachRefAtomIdentityPart::Name)?,
250        ),
251        "creator" => (
252            ForEachRefAtomIdentityRole::Creator,
253            plain(ForEachRefAtomIdentityPart::Full)?,
254        ),
255        _ => return None,
256    };
257    Some(ForEachRefAtom::Identity { peeled, role, part })
258}
259
260pub fn parse_for_each_ref_contents_lines_count(value: &str) -> Result<usize> {
261    value
262        .parse::<usize>()
263        .map_err(|_| GitError::Command(format!("invalid for-each-ref contents line count {value}")))
264}
265
266impl ForEachRefFormat {
267    pub fn parse(format_spec: &str) -> Result<Self> {
268        let inner = ExpandFormat::parse(format_spec, &ForEachRefAtomTable)?;
269        let segments = inner
270            .segments()
271            .iter()
272            .filter_map(|segment| match segment {
273                ExpandSegment::Literal(literal) => {
274                    Some(ForEachRefFormatSegment::Literal(literal.clone()))
275                }
276                ExpandSegment::Atom(atom) => Some(ForEachRefFormatSegment::Atom(atom.atom.clone())),
277                ExpandSegment::Padding(_) => None,
278            })
279            .collect();
280        Ok(Self { inner, segments })
281    }
282
283    pub fn segments(&self) -> &[ForEachRefFormatSegment] {
284        &self.segments
285    }
286
287    /// Mirror git's `need_color_reset_at_eol`: true when the format contains at
288    /// least one `%(color:...)` atom and the last such atom is not
289    /// `%(color:reset)`. The caller still gates this on color being enabled.
290    pub fn ends_with_unreset_color(&self) -> bool {
291        let mut need_reset = false;
292        for segment in &self.segments {
293            if let ForEachRefFormatSegment::Atom(ForEachRefAtom::Color(value)) = segment {
294                need_reset = value.trim() != "reset";
295            }
296        }
297        need_reset
298    }
299}
300
301pub fn write_for_each_ref_format(
302    stdout: &mut impl Write,
303    format: &ForEachRefFormat,
304    quote: ForEachRefQuoteMode,
305    reset_color_at_eol: bool,
306    mut write_atom: impl FnMut(&mut Vec<u8>, &ForEachRefAtom) -> Result<()>,
307) -> Result<()> {
308    if !format
309        .inner
310        .segments()
311        .iter()
312        .any(for_each_ref_segment_has_control)
313    {
314        format
315            .inner
316            .write_to(stdout, &mut write_atom, |stdout, value| {
317                write_for_each_ref_quoted_atom(stdout, value, quote)
318            })?;
319        if reset_color_at_eol {
320            stdout.write_all(b"\x1b[m")?;
321        }
322        return Ok(());
323    }
324
325    let mut rendered = Vec::new();
326    let (idx, stop) = write_for_each_ref_format_range(
327        &mut rendered,
328        format.inner.segments(),
329        0,
330        &[],
331        quote,
332        &mut write_atom,
333    )?;
334    if idx != format.inner.segments().len() || stop.is_some() {
335        return Err(GitError::Command(
336            "improper for-each-ref format control atom usage".into(),
337        ));
338    }
339    stdout.write_all(&rendered)?;
340    if reset_color_at_eol {
341        stdout.write_all(b"\x1b[m")?;
342    }
343    Ok(())
344}
345
346fn for_each_ref_segment_has_control(segment: &ExpandSegment<ForEachRefAtom>) -> bool {
347    match segment {
348        ExpandSegment::Atom(atom) => for_each_ref_control_atom(&atom.atom).is_some(),
349        ExpandSegment::Literal(_) | ExpandSegment::Padding(_) => false,
350    }
351}
352
353fn write_for_each_ref_format_range(
354    out: &mut Vec<u8>,
355    segments: &[ExpandSegment<ForEachRefAtom>],
356    mut idx: usize,
357    stops: &[ForEachRefControlStop],
358    quote: ForEachRefQuoteMode,
359    write_atom: &mut impl FnMut(&mut Vec<u8>, &ForEachRefAtom) -> Result<()>,
360) -> Result<(usize, Option<ForEachRefControlStop>)> {
361    let mut pending_padding = None;
362    while idx < segments.len() {
363        match &segments[idx] {
364            ExpandSegment::Literal(literal) => out.extend_from_slice(literal),
365            ExpandSegment::Padding(padding) => pending_padding = Some(*padding),
366            ExpandSegment::Atom(atom) => {
367                if let Some(control) = for_each_ref_control_atom(&atom.atom) {
368                    if let Some(stop) = control.stop()
369                        && stops.contains(&stop)
370                    {
371                        return Ok((idx, Some(stop)));
372                    }
373                    match control {
374                        ForEachRefControlAtom::Align(options) => {
375                            let (value, next) =
376                                render_for_each_ref_align(segments, idx + 1, &options, write_atom)?;
377                            let mut value = value;
378                            apply_for_each_ref_padding(&mut value, pending_padding.take());
379                            apply_for_each_ref_magic(out, atom.magic, &value);
380                            write_for_each_ref_quoted_atom(out, &value, quote)?;
381                            idx = next;
382                            continue;
383                        }
384                        ForEachRefControlAtom::If(condition) => {
385                            let (value, next) = render_for_each_ref_if(
386                                segments,
387                                idx + 1,
388                                &condition,
389                                quote,
390                                write_atom,
391                            )?;
392                            let mut value = value;
393                            apply_for_each_ref_padding(&mut value, pending_padding.take());
394                            apply_for_each_ref_magic(out, atom.magic, &value);
395                            out.extend_from_slice(&value);
396                            idx = next;
397                            continue;
398                        }
399                        ForEachRefControlAtom::Then
400                        | ForEachRefControlAtom::Else
401                        | ForEachRefControlAtom::End => {
402                            return Err(GitError::Command(
403                                "improper for-each-ref format control atom usage".into(),
404                            ));
405                        }
406                    }
407                }
408
409                let mut value = Vec::new();
410                write_atom(&mut value, &atom.atom)?;
411                apply_for_each_ref_padding(&mut value, pending_padding.take());
412                apply_for_each_ref_magic(out, atom.magic, &value);
413                write_for_each_ref_quoted_atom(out, &value, quote)?;
414            }
415        }
416        idx += 1;
417    }
418    Ok((idx, None))
419}
420
421fn render_for_each_ref_align(
422    segments: &[ExpandSegment<ForEachRefAtom>],
423    start: usize,
424    options: &ForEachRefAlignOptions,
425    write_atom: &mut impl FnMut(&mut Vec<u8>, &ForEachRefAtom) -> Result<()>,
426) -> Result<(Vec<u8>, usize)> {
427    let mut value = Vec::new();
428    let (idx, stop) = write_for_each_ref_format_range(
429        &mut value,
430        segments,
431        start,
432        &[ForEachRefControlStop::End],
433        ForEachRefQuoteMode::None,
434        write_atom,
435    )?;
436    if stop != Some(ForEachRefControlStop::End) {
437        return Err(GitError::Command("missing %(end) atom for %(align)".into()));
438    }
439    apply_for_each_ref_align(&mut value, options);
440    Ok((value, idx + 1))
441}
442
443fn render_for_each_ref_if(
444    segments: &[ExpandSegment<ForEachRefAtom>],
445    start: usize,
446    condition: &ForEachRefIfCondition,
447    quote: ForEachRefQuoteMode,
448    write_atom: &mut impl FnMut(&mut Vec<u8>, &ForEachRefAtom) -> Result<()>,
449) -> Result<(Vec<u8>, usize)> {
450    let mut test = Vec::new();
451    let (then_idx, stop) = write_for_each_ref_format_range(
452        &mut test,
453        segments,
454        start,
455        &[ForEachRefControlStop::Then],
456        ForEachRefQuoteMode::None,
457        write_atom,
458    )?;
459    if stop != Some(ForEachRefControlStop::Then) {
460        return Err(GitError::Command("missing %(then) atom for %(if)".into()));
461    }
462
463    let mut true_value = Vec::new();
464    let (branch_idx, branch_stop) = write_for_each_ref_format_range(
465        &mut true_value,
466        segments,
467        then_idx + 1,
468        &[ForEachRefControlStop::Else, ForEachRefControlStop::End],
469        quote,
470        write_atom,
471    )?;
472
473    let mut false_value = Vec::new();
474    let end_idx = match branch_stop {
475        Some(ForEachRefControlStop::End) => branch_idx,
476        Some(ForEachRefControlStop::Else) => {
477            let (idx, stop) = write_for_each_ref_format_range(
478                &mut false_value,
479                segments,
480                branch_idx + 1,
481                &[ForEachRefControlStop::End],
482                quote,
483                write_atom,
484            )?;
485            if stop != Some(ForEachRefControlStop::End) {
486                return Err(GitError::Command("missing %(end) atom for %(if)".into()));
487            }
488            idx
489        }
490        Some(ForEachRefControlStop::Then) | None => {
491            return Err(GitError::Command("missing %(end) atom for %(if)".into()));
492        }
493    };
494
495    let test = trim_ascii(&test);
496    let matched = match condition {
497        ForEachRefIfCondition::NonEmpty => !test.is_empty(),
498        ForEachRefIfCondition::Equals(value) => test == value.as_bytes(),
499        ForEachRefIfCondition::NotEquals(value) => test != value.as_bytes(),
500    };
501    Ok((if matched { true_value } else { false_value }, end_idx + 1))
502}
503
504#[derive(Debug, Clone, Copy, PartialEq, Eq)]
505enum ForEachRefControlStop {
506    Then,
507    Else,
508    End,
509}
510
511enum ForEachRefControlAtom {
512    Align(ForEachRefAlignOptions),
513    If(ForEachRefIfCondition),
514    Then,
515    Else,
516    End,
517}
518
519impl ForEachRefControlAtom {
520    fn stop(&self) -> Option<ForEachRefControlStop> {
521        match self {
522            Self::Then => Some(ForEachRefControlStop::Then),
523            Self::Else => Some(ForEachRefControlStop::Else),
524            Self::End => Some(ForEachRefControlStop::End),
525            Self::Align(_) | Self::If(_) => None,
526        }
527    }
528}
529
530#[derive(Clone, Copy)]
531enum ForEachRefAlignPosition {
532    Left,
533    Middle,
534    Right,
535}
536
537struct ForEachRefAlignOptions {
538    width: usize,
539    position: ForEachRefAlignPosition,
540}
541
542enum ForEachRefIfCondition {
543    NonEmpty,
544    Equals(String),
545    NotEquals(String),
546}
547
548fn for_each_ref_control_atom(atom: &ForEachRefAtom) -> Option<ForEachRefControlAtom> {
549    let ForEachRefAtom::Raw(value) = atom else {
550        return None;
551    };
552    if let Some(options) = value.strip_prefix("align:") {
553        return parse_for_each_ref_align_options(options).map(ForEachRefControlAtom::Align);
554    }
555    if value == "if" {
556        return Some(ForEachRefControlAtom::If(ForEachRefIfCondition::NonEmpty));
557    }
558    if let Some(expected) = value.strip_prefix("if:equals=") {
559        return Some(ForEachRefControlAtom::If(ForEachRefIfCondition::Equals(
560            expected.to_string(),
561        )));
562    }
563    if let Some(expected) = value.strip_prefix("if:notequals=") {
564        return Some(ForEachRefControlAtom::If(ForEachRefIfCondition::NotEquals(
565            expected.to_string(),
566        )));
567    }
568    match value.as_str() {
569        "then" => Some(ForEachRefControlAtom::Then),
570        "else" => Some(ForEachRefControlAtom::Else),
571        "end" => Some(ForEachRefControlAtom::End),
572        _ => None,
573    }
574}
575
576fn parse_for_each_ref_align_options(value: &str) -> Option<ForEachRefAlignOptions> {
577    let mut width = None;
578    let mut position = ForEachRefAlignPosition::Left;
579    for part in value.split(',') {
580        if let Some(rest) = part.strip_prefix("width=") {
581            width = rest.parse::<usize>().ok();
582        } else if let Some(rest) = part.strip_prefix("position=") {
583            position = parse_for_each_ref_align_position(rest)?;
584        } else if let Ok(parsed) = part.parse::<usize>() {
585            width = Some(parsed);
586        } else {
587            position = parse_for_each_ref_align_position(part)?;
588        }
589    }
590    Some(ForEachRefAlignOptions {
591        width: width?,
592        position,
593    })
594}
595
596fn parse_for_each_ref_align_position(value: &str) -> Option<ForEachRefAlignPosition> {
597    match value {
598        "left" => Some(ForEachRefAlignPosition::Left),
599        "middle" => Some(ForEachRefAlignPosition::Middle),
600        "right" => Some(ForEachRefAlignPosition::Right),
601        _ => None,
602    }
603}
604
605fn apply_for_each_ref_align(value: &mut Vec<u8>, options: &ForEachRefAlignOptions) {
606    let width = for_each_ref_display_width(value);
607    if width >= options.width {
608        return;
609    }
610    let extra = options.width - width;
611    let (left, right) = match options.position {
612        ForEachRefAlignPosition::Left => (0, extra),
613        ForEachRefAlignPosition::Middle => (extra / 2, extra - extra / 2),
614        ForEachRefAlignPosition::Right => (extra, 0),
615    };
616    let mut padded = Vec::with_capacity(value.len() + extra);
617    padded.extend(std::iter::repeat_n(b' ', left));
618    padded.extend_from_slice(value);
619    padded.extend(std::iter::repeat_n(b' ', right));
620    *value = padded;
621}
622
623fn apply_for_each_ref_magic(out: &mut Vec<u8>, magic: MagicPrefix, value: &[u8]) {
624    match (magic, value.is_empty()) {
625        (MagicPrefix::None, _) | (MagicPrefix::DeleteLfBeforeEmpty, _) => {}
626        (MagicPrefix::AddLfBeforeNonEmpty, false) => out.extend_from_slice(b"\n"),
627        (MagicPrefix::AddSpaceBeforeNonEmpty, false) => out.extend_from_slice(b" "),
628        (MagicPrefix::AddLfBeforeNonEmpty | MagicPrefix::AddSpaceBeforeNonEmpty, true) => {}
629    }
630    if magic == MagicPrefix::DeleteLfBeforeEmpty && value.is_empty() {
631        while out.last().copied() == Some(b'\n') {
632            out.pop();
633        }
634    }
635}
636
637fn apply_for_each_ref_padding(value: &mut Vec<u8>, padding: Option<PaddingSpec>) {
638    let Some(padding) = padding else {
639        return;
640    };
641    let width = for_each_ref_display_width(value);
642    let target = padding.width.max(0) as usize;
643    if width >= target {
644        return;
645    }
646    let extra = target - width;
647    let (left, right) = match padding.align {
648        PaddingAlign::Left => (0, extra),
649        PaddingAlign::Right | PaddingAlign::LeftAndSteal => (extra, 0),
650        PaddingAlign::Center => (extra / 2, extra - extra / 2),
651    };
652    let mut padded = Vec::with_capacity(value.len() + extra);
653    padded.extend(std::iter::repeat_n(b' ', left));
654    padded.extend_from_slice(value);
655    padded.extend(std::iter::repeat_n(b' ', right));
656    *value = padded;
657}
658
659fn for_each_ref_display_width(value: &[u8]) -> usize {
660    let mut width = 0usize;
661    let mut idx = 0usize;
662    while idx < value.len() {
663        if value[idx] == 0x1b
664            && value.get(idx + 1) == Some(&b'[')
665            && let Some(end) = value[idx + 2..]
666                .iter()
667                .position(|byte| (0x40..=0x7e).contains(byte))
668        {
669            idx += end + 3;
670            continue;
671        }
672        width += 1;
673        idx += 1;
674    }
675    width
676}
677
678fn trim_ascii(value: &[u8]) -> &[u8] {
679    let start = value
680        .iter()
681        .position(|byte| !byte.is_ascii_whitespace())
682        .unwrap_or(value.len());
683    let end = value
684        .iter()
685        .rposition(|byte| !byte.is_ascii_whitespace())
686        .map(|idx| idx + 1)
687        .unwrap_or(start);
688    &value[start..end]
689}
690
691#[derive(Debug, Default, Clone, Copy, Eq, PartialEq)]
692pub enum ForEachRefQuoteMode {
693    #[default]
694    None,
695    Shell,
696    Python,
697    Perl,
698    Tcl,
699}
700
701pub fn write_for_each_ref_quoted_atom(
702    stdout: &mut impl Write,
703    value: &[u8],
704    quote: ForEachRefQuoteMode,
705) -> Result<()> {
706    match quote {
707        ForEachRefQuoteMode::None => stdout.write_all(value)?,
708        ForEachRefQuoteMode::Shell => {
709            stdout.write_all(b"'")?;
710            for byte in value {
711                if *byte == b'\'' {
712                    stdout.write_all(br#"'\''"#)?;
713                } else {
714                    stdout.write_all(&[*byte])?;
715                }
716            }
717            stdout.write_all(b"'")?;
718        }
719        ForEachRefQuoteMode::Python | ForEachRefQuoteMode::Perl => {
720            stdout.write_all(b"'")?;
721            for byte in value {
722                match (*byte, quote) {
723                    (b'\\', _) => stdout.write_all(br#"\\"#)?,
724                    (b'\'', _) => stdout.write_all(br#"\'"#)?,
725                    (b'\n', ForEachRefQuoteMode::Python) => stdout.write_all(br#"\n"#)?,
726                    _ => stdout.write_all(&[*byte])?,
727                }
728            }
729            stdout.write_all(b"'")?;
730        }
731        ForEachRefQuoteMode::Tcl => {
732            stdout.write_all(b"\"")?;
733            for byte in value {
734                match *byte {
735                    b'\\' => stdout.write_all(br#"\\"#)?,
736                    b'"' => stdout.write_all(br#"\""#)?,
737                    b'\n' => stdout.write_all(br#"\n"#)?,
738                    _ => stdout.write_all(&[*byte])?,
739                }
740            }
741            stdout.write_all(b"\"")?;
742        }
743    }
744    Ok(())
745}
746
747#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
748pub struct ForEachRefTrack {
749    pub ahead: usize,
750    pub behind: usize,
751    /// The upstream is configured but its ref no longer resolves; git renders
752    /// `%(upstream:track)` as `[gone]` and `%(upstream:trackshort)` as empty.
753    pub gone: bool,
754}
755
756#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
757pub enum ForEachRefEmailMode {
758    #[default]
759    Bracketed,
760    Trim,
761    LocalPart,
762}
763
764pub fn write_for_each_ref_track(
765    stdout: &mut impl Write,
766    track: ForEachRefTrack,
767    bracketed: bool,
768) -> Result<()> {
769    if track.gone {
770        // git emits a literal "[gone]" (or bare "gone" with nobracket) when the
771        // configured upstream no longer resolves.
772        if bracketed {
773            stdout.write_all(b"[gone]")?;
774        } else {
775            stdout.write_all(b"gone")?;
776        }
777        return Ok(());
778    }
779    if bracketed && (track.ahead > 0 || track.behind > 0) {
780        stdout.write_all(b"[")?;
781    }
782    match (track.ahead, track.behind) {
783        (0, _) => {}
784        (ahead, 0) => write!(stdout, "ahead {ahead}")?,
785        (ahead, behind) => write!(stdout, "ahead {ahead}, behind {behind}")?,
786    }
787    if track.ahead == 0 && track.behind > 0 {
788        write!(stdout, "behind {}", track.behind)?;
789    }
790    if bracketed && (track.ahead > 0 || track.behind > 0) {
791        stdout.write_all(b"]")?;
792    }
793    Ok(())
794}
795
796pub fn for_each_ref_track_short(track: ForEachRefTrack) -> &'static str {
797    if track.gone {
798        // git's trackshort is empty for a gone upstream.
799        return "";
800    }
801    match (track.ahead, track.behind) {
802        (0, 0) => "=",
803        (_, 0) => ">",
804        (0, _) => "<",
805        (_, _) => "<>",
806    }
807}
808
809pub fn write_for_each_ref_identity(stdout: &mut impl Write, identity: Option<&[u8]>) -> Result<()> {
810    if let Some(identity) = identity {
811        stdout.write_all(identity)?;
812    }
813    Ok(())
814}
815
816pub fn write_for_each_ref_identity_name(
817    stdout: &mut impl Write,
818    identity: Option<&[u8]>,
819) -> Result<()> {
820    if let Some(identity) = identity
821        && let Some(name) = for_each_ref_identity_name(identity)
822    {
823        stdout.write_all(name)?;
824    }
825    Ok(())
826}
827
828pub fn write_for_each_ref_identity_email(
829    stdout: &mut impl Write,
830    identity: Option<&[u8]>,
831) -> Result<()> {
832    write_for_each_ref_identity_email_mode(stdout, identity, ForEachRefEmailMode::Bracketed)
833}
834
835pub fn write_for_each_ref_identity_email_mode(
836    stdout: &mut impl Write,
837    identity: Option<&[u8]>,
838    mode: ForEachRefEmailMode,
839) -> Result<()> {
840    if let Some(identity) = identity
841        && let Some(email) = for_each_ref_identity_email(identity, mode)
842    {
843        stdout.write_all(email)?;
844    }
845    Ok(())
846}
847
848pub fn write_for_each_ref_identity_date_raw(
849    stdout: &mut impl Write,
850    identity: Option<&[u8]>,
851) -> Result<()> {
852    if let Some(identity) = identity
853        && let Some(date) = for_each_ref_identity_date_raw(identity)
854    {
855        stdout.write_all(date)?;
856    }
857    Ok(())
858}
859
860pub fn write_for_each_ref_identity_date(
861    stdout: &mut impl Write,
862    identity: Option<&[u8]>,
863) -> Result<()> {
864    write_for_each_ref_identity_date_mode(stdout, identity, &DateMode::Default)
865}
866
867pub fn write_for_each_ref_identity_date_mode(
868    stdout: &mut impl Write,
869    identity: Option<&[u8]>,
870    mode: &DateMode,
871) -> Result<()> {
872    if let Some(identity) = identity
873        && let Some(date) = for_each_ref_identity_date(identity, mode)
874    {
875        stdout.write_all(date.as_bytes())?;
876    }
877    Ok(())
878}
879
880pub fn for_each_ref_identity_name(identity: &[u8]) -> Option<&[u8]> {
881    let marker = identity.windows(2).position(|window| window == b" <")?;
882    Some(&identity[..marker])
883}
884
885pub fn for_each_ref_identity_email(identity: &[u8], mode: ForEachRefEmailMode) -> Option<&[u8]> {
886    let start = identity.iter().position(|byte| *byte == b'<')?;
887    let end = identity[start..].iter().position(|byte| *byte == b'>')?;
888    let bracketed = &identity[start..=start + end];
889    match mode {
890        ForEachRefEmailMode::Bracketed => Some(bracketed),
891        ForEachRefEmailMode::Trim => Some(&identity[start + 1..start + end]),
892        ForEachRefEmailMode::LocalPart => {
893            let trimmed = &identity[start + 1..start + end];
894            let at = trimmed.iter().position(|byte| *byte == b'@')?;
895            Some(&trimmed[..at])
896        }
897    }
898}
899
900pub fn for_each_ref_identity_date_raw(identity: &[u8]) -> Option<&[u8]> {
901    // Locate the timestamp+timezone tail git's way (scanning back from the end
902    // for the last '>'), then return the contiguous `<digits> <tz>` slice.
903    let fields = sley_core::split_ident_line(identity)?;
904    let date = fields.date?;
905    let tz = fields.tz?;
906    let base = identity.as_ptr() as usize;
907    let start = date.as_ptr() as usize - base;
908    let end = (tz.as_ptr() as usize - base) + tz.len();
909    Some(&identity[start..end])
910}
911
912pub fn for_each_ref_identity_date(identity: &[u8], mode: &DateMode) -> Option<String> {
913    // git's show_ident_date semantics: an out-of-range timestamp renders the
914    // epoch sentinel rather than dropping the field; a missing date renders
915    // nothing (None).
916    let fields = sley_core::split_ident_line(identity)?;
917    let date = fields.date?;
918    let tz = fields.tz.unwrap_or(b"+0000");
919    Some(sley_core::ident_render_date(date, tz, mode))
920}
921
922pub fn for_each_ref_identity_timestamp(identity: &[u8]) -> Option<i64> {
923    let fields = sley_core::split_ident_line(identity)?;
924    let date = fields.date?;
925    std::str::from_utf8(date).ok()?.parse::<i64>().ok()
926}
927
928/// The signature begin-markers git recognizes (`gpg-interface.c` format table).
929/// A message line beginning with one of these starts the trailing signature.
930const FOR_EACH_REF_SIGNATURE_MARKERS: [&[u8]; 4] = [
931    b"-----BEGIN PGP SIGNATURE-----",
932    b"-----BEGIN PGP MESSAGE-----",
933    b"-----BEGIN SIGNED MESSAGE-----",
934    b"-----BEGIN SSH SIGNATURE-----",
935];
936
937/// Offset into `message` where the trailing signature begins, or the message
938/// length when unsigned. Mirrors gpg-interface.c `parse_signed_buffer`: the
939/// LAST line that starts with a signature marker wins.
940fn for_each_ref_signature_start(message: &[u8]) -> usize {
941    let mut start = 0;
942    let mut sig = message.len();
943    while start < message.len() {
944        let line = &message[start..];
945        if FOR_EACH_REF_SIGNATURE_MARKERS
946            .iter()
947            .any(|marker| line.starts_with(marker))
948        {
949            sig = start;
950        }
951        match line.iter().position(|byte| *byte == b'\n') {
952            Some(eol) => start += eol + 1,
953            None => break,
954        }
955    }
956    sig
957}
958
959/// The split of a commit/tag message into the regions git's for-each-ref atoms
960/// expose, mirroring ref-filter.c `find_subpos`.
961pub struct ForEachRefMessageParts<'a> {
962    /// The subject line(s), with no trailing newline (raw bytes; callers run
963    /// `for_each_ref_copy_subject` to collapse embedded newlines).
964    pub subject: &'a [u8],
965    /// `%(contents:body)` — body with the signature removed.
966    pub body_without_sig: &'a [u8],
967    /// `%(body)` (legacy) — body *including* the signature.
968    pub body_with_sig: &'a [u8],
969    /// `%(contents:signature)` — the trailing signature block (may be empty).
970    pub signature: &'a [u8],
971    /// `%(contents)` / `%(contents:size)` — the message from the subject start
972    /// (after leading blank lines) to the end.
973    pub bare: &'a [u8],
974}
975
976/// Split a commit/tag message into the for-each-ref content regions, mirroring
977/// ref-filter.c `find_subpos`. `message` is the header-stripped message (sley
978/// already strips object headers before this point).
979pub fn for_each_ref_message_parts(message: &[u8]) -> ForEachRefMessageParts<'_> {
980    // Skip any leading empty lines (the header/body separator is already gone).
981    let mut start = 0;
982    while message.get(start) == Some(&b'\n') {
983        start += 1;
984    }
985    let buf = &message[start..];
986    let bare = buf;
987    let sigstart = for_each_ref_signature_start(buf);
988    let signature = &buf[sigstart..];
989
990    // Subject runs to the first blank line before the signature, else to the
991    // signature start (treating the whole pre-sig message as subject).
992    let subject_region = &buf[..sigstart];
993    let subject_end = for_each_ref_blank_line(subject_region).unwrap_or(sigstart);
994    let mut sublen = subject_end;
995    while sublen > 0 && matches!(buf[sublen - 1], b'\n' | b'\r') {
996        sublen -= 1;
997    }
998    let subject = &buf[..sublen];
999
1000    // Body begins after the subject's trailing blank lines.
1001    let mut body_start = subject_end;
1002    while body_start < buf.len() && matches!(buf[body_start], b'\n' | b'\r') {
1003        body_start += 1;
1004    }
1005    let body_with_sig = &buf[body_start..];
1006    let body_without_sig = &buf[body_start..sigstart.max(body_start)];
1007    ForEachRefMessageParts {
1008        subject,
1009        body_without_sig,
1010        body_with_sig,
1011        signature,
1012        bare,
1013    }
1014}
1015
1016/// Find the byte offset of the first blank-line separator (`\n\n` or
1017/// `\r\n\r\n`) in `buf`, returning the offset of the first newline of the pair.
1018fn for_each_ref_blank_line(buf: &[u8]) -> Option<usize> {
1019    let lf = buf.windows(2).position(|window| window == b"\n\n");
1020    let crlf = buf.windows(4).position(|window| window == b"\r\n\r\n");
1021    match (lf, crlf) {
1022        (Some(a), Some(b)) => Some(a.min(b)),
1023        (Some(a), None) => Some(a),
1024        (None, Some(b)) => Some(b),
1025        (None, None) => None,
1026    }
1027}
1028
1029/// `copy_subject`: render the subject with embedded newlines turned into single
1030/// spaces (CRLF's CR is dropped), matching ref-filter.c.
1031pub fn for_each_ref_copy_subject(subject: &[u8]) -> String {
1032    let mut out = String::with_capacity(subject.len());
1033    let mut idx = 0;
1034    while idx < subject.len() {
1035        let byte = subject[idx];
1036        if byte == b'\r' && subject.get(idx + 1) == Some(&b'\n') {
1037            idx += 1;
1038            continue;
1039        }
1040        if byte == b'\n' {
1041            out.push(' ');
1042        } else {
1043            out.push(byte as char);
1044        }
1045        idx += 1;
1046    }
1047    out
1048}
1049
1050/// `format_sanitized_subject`: replace non-title-character runs with a single
1051/// `-`, collapse consecutive `.`, and trim trailing `.`/`-` (pretty.c).
1052pub fn for_each_ref_sanitize_subject(subject: &str) -> String {
1053    let bytes = subject.as_bytes();
1054    let mut out = Vec::with_capacity(bytes.len());
1055    let mut space = 2u8; // git's initial `space = 2`
1056    let mut idx = 0;
1057    while idx < bytes.len() {
1058        let byte = bytes[idx];
1059        if for_each_ref_istitlechar(byte) {
1060            if space == 1 {
1061                out.push(b'-');
1062            }
1063            space = 0;
1064            out.push(byte);
1065            if byte == b'.' {
1066                while bytes.get(idx + 1) == Some(&b'.') {
1067                    idx += 1;
1068                }
1069            }
1070        } else {
1071            space |= 1;
1072        }
1073        idx += 1;
1074    }
1075    while matches!(out.last(), Some(b'.') | Some(b'-')) {
1076        out.pop();
1077    }
1078    String::from_utf8_lossy(&out).into_owned()
1079}
1080
1081fn for_each_ref_istitlechar(byte: u8) -> bool {
1082    byte.is_ascii_alphanumeric() || byte == b'.' || byte == b'_'
1083}
1084
1085pub fn for_each_ref_short_name(refname: &str) -> &str {
1086    if let Some(remote) = refname.strip_prefix("refs/remotes/")
1087        && let Some(remote_name) = remote.strip_suffix("/HEAD")
1088    {
1089        return remote_name;
1090    }
1091    refname
1092        .strip_prefix("refs/heads/")
1093        .or_else(|| refname.strip_prefix("refs/tags/"))
1094        .or_else(|| refname.strip_prefix("refs/remotes/"))
1095        .unwrap_or(refname)
1096}
1097
1098/// git's `ref_rev_parse_rules`: the format patterns tried (shortest-name first)
1099/// when resolving an abbreviated ref, and in reverse when shortening one.
1100const REF_REV_PARSE_RULES: [&str; 6] = [
1101    "{}",
1102    "refs/{}",
1103    "refs/tags/{}",
1104    "refs/heads/{}",
1105    "refs/remotes/{}",
1106    "refs/remotes/{}/HEAD",
1107];
1108
1109fn expand_ref_rule(rule: &str, short: &str) -> String {
1110    rule.replace("{}", short)
1111}
1112
1113/// Strip the prefix/suffix of a rev-parse rule from `refname`, returning the
1114/// `%.*s` portion if the rule matches (git's `match_parse_rule`).
1115fn match_ref_parse_rule<'a>(refname: &'a str, rule: &str) -> Option<&'a str> {
1116    let (prefix, suffix) = rule.split_once("{}").expect("rule contains {}");
1117    refname
1118        .strip_prefix(prefix)
1119        .and_then(|rest| rest.strip_suffix(suffix))
1120}
1121
1122/// git's `shorten_unambiguous_ref`: find the shortest abbreviation of `refname`
1123/// that, under the rev-parse rules, resolves back to exactly this ref.
1124/// `strict` (git's `core.warnambiguousrefs`, default true) requires *all* other
1125/// rules to fail; otherwise only rules that sort before the matched one matter.
1126/// `ref_exists` reports whether a fully-qualified refname is present.
1127pub fn shorten_unambiguous_ref(
1128    refname: &str,
1129    strict: bool,
1130    ref_exists: impl Fn(&str) -> bool,
1131) -> String {
1132    // Skip rule 0 ("{}"), which always matches.
1133    for matched in (1..REF_REV_PARSE_RULES.len()).rev() {
1134        let Some(short) = match_ref_parse_rule(refname, REF_REV_PARSE_RULES[matched]) else {
1135            continue;
1136        };
1137        let rules_to_fail = if strict {
1138            REF_REV_PARSE_RULES.len()
1139        } else {
1140            matched
1141        };
1142        let ambiguous = (0..rules_to_fail).any(|rule_idx| {
1143            rule_idx != matched
1144                && ref_exists(&expand_ref_rule(REF_REV_PARSE_RULES[rule_idx], short))
1145        });
1146        if !ambiguous {
1147            return short.to_string();
1148        }
1149    }
1150    refname.to_string()
1151}
1152
1153pub fn parse_for_each_ref_strip_count(value: &str) -> Result<isize> {
1154    value
1155        .parse::<isize>()
1156        .map_err(|_| GitError::Command(format!("invalid refname strip count {value}")))
1157}
1158
1159pub fn for_each_ref_lstrip_name(refname: &str, count: isize) -> String {
1160    let components = refname.split('/').collect::<Vec<_>>();
1161    if count == 0 {
1162        return refname.to_string();
1163    }
1164    let start = if count > 0 {
1165        (count as usize).min(components.len())
1166    } else {
1167        components.len().saturating_sub(count.unsigned_abs())
1168    };
1169    components[start..].join("/")
1170}
1171
1172pub fn for_each_ref_rstrip_name(refname: &str, count: isize) -> String {
1173    let components = refname.split('/').collect::<Vec<_>>();
1174    if count == 0 {
1175        return refname.to_string();
1176    }
1177    let end = if count > 0 {
1178        components.len().saturating_sub(count as usize)
1179    } else {
1180        count.unsigned_abs().min(components.len())
1181    };
1182    components[..end].join("/")
1183}
1184
1185pub fn for_each_ref_abbrev_oid(
1186    oid: &ObjectId,
1187    width: Option<usize>,
1188    candidates: &[ObjectId],
1189) -> String {
1190    let hex = oid.to_hex();
1191    let mut width = oid.abbrev_hex_len(width.unwrap_or(hex.len()));
1192    while width < hex.len() {
1193        let prefix = &hex.as_bytes()[..width];
1194        if !candidates
1195            .iter()
1196            .any(|candidate| candidate != oid && candidate.hex_prefix_matches(prefix))
1197        {
1198            break;
1199        }
1200        width += 1;
1201    }
1202    hex[..width].to_string()
1203}
1204
1205pub fn parse_for_each_ref_abbrev_width(value: &str) -> Result<usize> {
1206    let width = value
1207        .parse::<usize>()
1208        .ok()
1209        .filter(|width| *width > 0)
1210        .ok_or_else(|| {
1211            GitError::Command(format!(
1212                "positive value expected in for-each-ref objectname:short format: {value}"
1213            ))
1214        })?;
1215    Ok(width.max(4))
1216}
1217
1218pub fn commit_identity_date(raw: &[u8], mode: &DateMode) -> String {
1219    for_each_ref_identity_date(raw, mode).unwrap_or_default()
1220}
1221
1222/// Render an ident's date for the structured header lines (`Date:`/`AuthorDate:`/
1223/// `CommitDate:`), mirroring pretty.c's `pp_user_info`, which calls
1224/// `show_ident_date` directly: a missing or unparsable date still prints the
1225/// epoch sentinel (`Thu Jan 1 00:00:00 1970 +0000`) rather than an empty string.
1226/// Use this for the medium/full/fuller layouts; use [`commit_identity_date`] for
1227/// the `%ad`/`%cd` placeholders, which suppress a missing date entirely.
1228pub fn commit_identity_date_or_sentinel(raw: &[u8], mode: &DateMode) -> String {
1229    match sley_core::split_ident_line(raw) {
1230        Some(fields) => {
1231            let date = fields.date.unwrap_or(b"0");
1232            let tz = fields.tz.unwrap_or(b"+0000");
1233            sley_core::ident_render_date(date, tz, mode)
1234        }
1235        // No `<…>` pair at all: pp_user_info would skip the whole block, so the
1236        // caller shouldn't reach here for a well-formed commit; fall back to the
1237        // epoch sentinel to stay non-panicking.
1238        None => sley_core::ident_render_date(b"0", b"+0000", mode),
1239    }
1240}
1241
1242#[cfg(test)]
1243mod tests {
1244    use super::*;
1245    use sley_core::ObjectFormat;
1246
1247    #[test]
1248    fn format_parser_decodes_literals_atoms_and_percent_escapes() {
1249        let format =
1250            ForEachRefFormat::parse("refs/%%/%(refname)%09%(objectname)%q").expect("valid format");
1251        assert_eq!(
1252            format.segments(),
1253            &[
1254                ForEachRefFormatSegment::Literal(b"refs/%/".to_vec()),
1255                ForEachRefFormatSegment::Atom(ForEachRefAtom::RefName {
1256                    source: ForEachRefNameSource::Ref,
1257                    format: ForEachRefNameFormat::Full
1258                }),
1259                ForEachRefFormatSegment::Literal(b"\t".to_vec()),
1260                ForEachRefFormatSegment::Atom(ForEachRefAtom::ObjectName {
1261                    peeled: false,
1262                    abbrev: None
1263                }),
1264                ForEachRefFormatSegment::Literal(b"%q".to_vec()),
1265            ]
1266        );
1267    }
1268
1269    #[test]
1270    fn format_parser_decodes_typed_ref_filter_atoms() {
1271        let format = ForEachRefFormat::parse(
1272            "%(refname:short) %(upstream:lstrip=2) %(*objectname:short=7) %(authoremail:trim) %(authordate:iso8601-strict) %(*contents:lines=2)",
1273        )
1274        .expect("valid format");
1275        assert_eq!(
1276            format.segments(),
1277            &[
1278                ForEachRefFormatSegment::Atom(ForEachRefAtom::RefName {
1279                    source: ForEachRefNameSource::Ref,
1280                    format: ForEachRefNameFormat::Short,
1281                }),
1282                ForEachRefFormatSegment::Literal(b" ".to_vec()),
1283                ForEachRefFormatSegment::Atom(ForEachRefAtom::RefName {
1284                    source: ForEachRefNameSource::Upstream,
1285                    format: ForEachRefNameFormat::Strip(ForEachRefStrip {
1286                        direction: ForEachRefStripDirection::Left,
1287                        count: 2,
1288                    }),
1289                }),
1290                ForEachRefFormatSegment::Literal(b" ".to_vec()),
1291                ForEachRefFormatSegment::Atom(ForEachRefAtom::ObjectName {
1292                    peeled: true,
1293                    abbrev: Some(7),
1294                }),
1295                ForEachRefFormatSegment::Literal(b" ".to_vec()),
1296                // `name`/`email`/`date` atoms that carry a `:modifier` are now
1297                // kept as Raw placeholders; the CLI's string renderer owns the
1298                // full option grammar (mailmap, multi-option, all date modes)
1299                // and the byte-exact bad-argument errors.
1300                ForEachRefFormatSegment::Atom(ForEachRefAtom::Raw("authoremail:trim".to_string())),
1301                ForEachRefFormatSegment::Literal(b" ".to_vec()),
1302                ForEachRefFormatSegment::Atom(ForEachRefAtom::Raw(
1303                    "authordate:iso8601-strict".to_string(),
1304                )),
1305                ForEachRefFormatSegment::Literal(b" ".to_vec()),
1306                ForEachRefFormatSegment::Atom(ForEachRefAtom::ContentsLines {
1307                    peeled: true,
1308                    count: 2,
1309                }),
1310            ]
1311        );
1312    }
1313
1314    #[test]
1315    fn format_parser_rejects_unterminated_atoms() {
1316        assert!(ForEachRefFormat::parse("%(refname").is_err());
1317    }
1318
1319    #[test]
1320    fn format_parser_rejects_invalid_typed_atom_numbers() {
1321        assert!(ForEachRefFormat::parse("%(contents:lines=nope)").is_err());
1322        assert!(ForEachRefFormat::parse("%(objectname:short=0)").is_err());
1323        assert!(ForEachRefFormat::parse("%(refname:lstrip=nope)").is_err());
1324    }
1325
1326    #[test]
1327    fn format_renderer_streams_literals_atoms_and_quotes() {
1328        let format = ForEachRefFormat::parse("branch=%(refname)").expect("valid format");
1329        let mut out = Vec::new();
1330        write_for_each_ref_format(
1331            &mut out,
1332            &format,
1333            ForEachRefQuoteMode::Shell,
1334            false,
1335            |atom, name| {
1336                assert_eq!(
1337                    name,
1338                    &ForEachRefAtom::RefName {
1339                        source: ForEachRefNameSource::Ref,
1340                        format: ForEachRefNameFormat::Full
1341                    }
1342                );
1343                atom.extend_from_slice(b"main's");
1344                Ok(())
1345            },
1346        )
1347        .expect("writes to in-memory buffer");
1348        assert_eq!(out, b"branch='main'\\''s'");
1349    }
1350
1351    #[test]
1352    fn format_renderer_uses_shared_padding_and_magic() {
1353        let format =
1354            ForEachRefFormat::parse("x\n%-(*objectname)%>(6)%(refname)").expect("valid format");
1355        let mut out = Vec::new();
1356        write_for_each_ref_format(
1357            &mut out,
1358            &format,
1359            ForEachRefQuoteMode::None,
1360            false,
1361            |value, atom| {
1362                match atom {
1363                    ForEachRefAtom::ObjectName { peeled: true, .. } => {}
1364                    ForEachRefAtom::RefName { .. } => value.extend_from_slice(b"main"),
1365                    other => panic!("unexpected atom {other:?}"),
1366                }
1367                Ok(())
1368            },
1369        )
1370        .expect("writes to in-memory buffer");
1371        assert_eq!(out, b"x  main");
1372    }
1373
1374    #[test]
1375    fn identity_parts_match_git_identity_layout() {
1376        let ident = b"Ada Lovelace <ada@example.com> 1717430401 -0530";
1377        assert_eq!(
1378            for_each_ref_identity_name(ident),
1379            Some(&b"Ada Lovelace"[..])
1380        );
1381        assert_eq!(
1382            for_each_ref_identity_email(ident, ForEachRefEmailMode::Bracketed),
1383            Some(&b"<ada@example.com>"[..])
1384        );
1385        assert_eq!(
1386            for_each_ref_identity_email(ident, ForEachRefEmailMode::Trim),
1387            Some(&b"ada@example.com"[..])
1388        );
1389        assert_eq!(
1390            for_each_ref_identity_email(ident, ForEachRefEmailMode::LocalPart),
1391            Some(&b"ada"[..])
1392        );
1393        assert_eq!(for_each_ref_identity_timestamp(ident), Some(1717430401));
1394        assert_eq!(
1395            for_each_ref_identity_date(ident, &DateMode::Raw).as_deref(),
1396            Some("1717430401 -0530")
1397        );
1398    }
1399
1400    #[test]
1401    fn dates_use_identity_timezone() {
1402        let ident = b"Ada <ada@example.com> 1717430401 -0530";
1403        assert_eq!(
1404            for_each_ref_identity_date(ident, &DateMode::Short).as_deref(),
1405            Some("2024-06-03")
1406        );
1407        assert_eq!(
1408            for_each_ref_identity_date(ident, &DateMode::IsoStrict).as_deref(),
1409            Some("2024-06-03T10:30:01-05:30")
1410        );
1411    }
1412
1413    #[test]
1414    fn tracking_formats_match_ref_filter_atoms() {
1415        assert_eq!(
1416            for_each_ref_track_short(ForEachRefTrack {
1417                ahead: 0,
1418                behind: 0,
1419                gone: false,
1420            }),
1421            "="
1422        );
1423        assert_eq!(
1424            for_each_ref_track_short(ForEachRefTrack {
1425                ahead: 1,
1426                behind: 0,
1427                gone: false,
1428            }),
1429            ">"
1430        );
1431        assert_eq!(
1432            for_each_ref_track_short(ForEachRefTrack {
1433                ahead: 0,
1434                behind: 1,
1435                gone: false,
1436            }),
1437            "<"
1438        );
1439        assert_eq!(
1440            for_each_ref_track_short(ForEachRefTrack {
1441                ahead: 1,
1442                behind: 1,
1443                gone: false,
1444            }),
1445            "<>"
1446        );
1447
1448        let mut out = Vec::new();
1449        write_for_each_ref_track(
1450            &mut out,
1451            ForEachRefTrack {
1452                ahead: 2,
1453                behind: 3,
1454                gone: false,
1455            },
1456            true,
1457        )
1458        .expect("writes to in-memory buffer");
1459        assert_eq!(out, b"[ahead 2, behind 3]");
1460    }
1461
1462    #[test]
1463    fn refname_shortening_and_stripping_match_ref_filter_rules() {
1464        assert_eq!(for_each_ref_short_name("refs/heads/main"), "main");
1465        assert_eq!(for_each_ref_short_name("refs/tags/v1"), "v1");
1466        assert_eq!(
1467            for_each_ref_short_name("refs/remotes/origin/HEAD"),
1468            "origin"
1469        );
1470        assert_eq!(for_each_ref_lstrip_name("refs/heads/main", 2), "main");
1471        assert_eq!(for_each_ref_lstrip_name("refs/heads/main", -1), "main");
1472        assert_eq!(for_each_ref_rstrip_name("refs/heads/main", 1), "refs/heads");
1473        assert_eq!(
1474            for_each_ref_rstrip_name("refs/heads/main", -2),
1475            "refs/heads"
1476        );
1477    }
1478
1479    #[test]
1480    fn abbreviations_extend_to_avoid_ambiguity() {
1481        let one = ObjectId::from_hex(
1482            ObjectFormat::Sha1,
1483            "1111111111111111111111111111111111111111",
1484        )
1485        .expect("valid object id");
1486        let two = ObjectId::from_hex(
1487            ObjectFormat::Sha1,
1488            "1111122222222222222222222222222222222222",
1489        )
1490        .expect("valid object id");
1491        assert_eq!(
1492            parse_for_each_ref_abbrev_width("2").expect("valid abbrev width"),
1493            4
1494        );
1495        assert_eq!(
1496            for_each_ref_abbrev_oid(&one, Some(4), &[one.clone(), two]),
1497            "111111"
1498        );
1499    }
1500}