Skip to main content

grit_lib/
ident.rs

1//! Git author/committer identity lines (`ident` in Git's `fsck.c` / `commit.c`).
2//!
3//! Parses `Name <email> <unix-timestamp> <+HHMM>` with the same edge cases as Git:
4//! overflow, non-digit timestamps, and whitespace-only timestamps (sentinel handling).
5//!
6//! ```rust
7//! use grit_lib::ident::parse_author_arg;
8//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
9//! let contact = parse_author_arg("Ada Lovelace <ada@example.com>")?;
10//! assert_eq!(contact.name, "Ada Lovelace");
11//! assert_eq!(contact.email, "ada@example.com");
12//! # Ok(())
13//! # }
14//! ```
15
16use crate::commit::{assemble_identity, format_git_timestamp};
17use crate::git_date::tm::date_overflows;
18use crate::objects::{HashAlgo, ObjectKind};
19use time::format_description::well_known::Rfc3339;
20use time::{OffsetDateTime, UtcOffset};
21
22/// Parsed timestamp from a signature line for display and filtering.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum SignatureTimestamp {
25    /// Parsed seconds since Unix epoch (author/committer field); safe for `time_t` / display.
26    Valid(i64),
27    /// Unparsable, overflowing, or whitespace-only date field — Git uses a sentinel (epoch in
28    /// headers, empty `%ad`, empty `%at` / `%ct` in format).
29    Sentinel,
30}
31
32/// Parsed author or committer identity from a full header or reflog identity line.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Signature {
35    /// Display name (may be empty).
36    pub name: String,
37    /// Email address inside `<…>`.
38    pub email: String,
39    /// Timestamp with the recorded `±HHMM` offset preserved.
40    pub time: OffsetDateTime,
41}
42
43impl Signature {
44    /// Build a signature for writing into a commit or tag.
45    ///
46    /// # Parameters
47    ///
48    /// - `name`: display name (may be empty).
49    /// - `email`: address written inside `<…>` (may be empty).
50    /// - `time`: timestamp; its UTC offset becomes the `±HHMM` field.
51    ///
52    /// # Errors
53    ///
54    /// See [`Self::validate`].
55    ///
56    /// # Examples
57    ///
58    /// ```
59    /// use grit_lib::ident::Signature;
60    /// use time::{OffsetDateTime, UtcOffset};
61    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
62    /// let time = OffsetDateTime::from_unix_timestamp(1_700_000_000)?
63    ///     .to_offset(UtcOffset::from_hms(-8, 0, 0)?);
64    /// let sig = Signature::new("Ada Lovelace", "ada@example.com", time)?;
65    /// assert_eq!(sig.header_line(), "Ada Lovelace <ada@example.com> 1700000000 -0800");
66    /// assert!(Signature::new("Ada <x>", "ada@example.com", time).is_err());
67    /// # Ok(())
68    /// # }
69    /// ```
70    pub fn new(
71        name: impl Into<String>,
72        email: impl Into<String>,
73        time: OffsetDateTime,
74    ) -> Result<Self, SignatureError> {
75        let signature = Self {
76            name: name.into(),
77            email: email.into(),
78            time,
79        };
80        signature.validate()?;
81        Ok(signature)
82    }
83
84    /// Check that [`Self::header_line`] yields an identity line `git fsck` accepts.
85    ///
86    /// # Errors
87    ///
88    /// - [`SignatureError::ForbiddenCharacter`] when the name or email contains `<`, `>`,
89    ///   or a control character other than tab.
90    /// - [`SignatureError::BeforeEpoch`] when the time is before 1970-01-01 UTC.
91    /// - [`SignatureError::SubMinuteOffset`] when the UTC offset has a seconds component,
92    ///   which the `±HHMM` field cannot represent.
93    pub fn validate(&self) -> Result<(), SignatureError> {
94        for (field, value) in [
95            (SignatureField::Name, &self.name),
96            (SignatureField::Email, &self.email),
97        ] {
98            if let Some(ch) = value
99                .chars()
100                .find(|&c| c == '<' || c == '>' || (c.is_ascii_control() && c != '\t'))
101            {
102                return Err(SignatureError::ForbiddenCharacter { field, ch });
103            }
104        }
105        if self.time.unix_timestamp() < 0 {
106            return Err(SignatureError::BeforeEpoch);
107        }
108        if self.time.offset().seconds_past_minute() != 0 {
109            return Err(SignatureError::SubMinuteOffset);
110        }
111        Ok(())
112    }
113
114    /// `Name <email>` for a reflog identity column; the timestamp is taken from
115    /// [`crate::refs::store::ReflogUpdate::time`] when the entry is formatted.
116    #[must_use]
117    pub fn reflog_name_email(&self) -> String {
118        format!("{} <{}>", self.name, self.email)
119    }
120
121    /// Render as a header payload: `Name <email> <unix-seconds> <±HHMM>`.
122    ///
123    /// The result is what follows `author ` / `committer ` / `tagger ` in an object.
124    /// It is only fsck-clean when [`Self::validate`] succeeds.
125    #[must_use]
126    pub fn header_line(&self) -> String {
127        format!(
128            "{} <{}> {}",
129            self.name,
130            self.email,
131            format_git_timestamp(self.time)
132        )
133    }
134
135    /// Format [`Self::time`] as RFC 3339 (includes the original offset).
136    #[must_use]
137    pub fn format_rfc3339(&self) -> Option<String> {
138        self.time.format(&Rfc3339).ok()
139    }
140}
141
142/// Which part of a [`Signature`] a [`SignatureError`] refers to.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum SignatureField {
145    /// [`Signature::name`].
146    Name,
147    /// [`Signature::email`].
148    Email,
149}
150
151impl std::fmt::Display for SignatureField {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        f.write_str(match self {
154            Self::Name => "name",
155            Self::Email => "email",
156        })
157    }
158}
159
160/// Why a [`Signature`] cannot be written into an object (see [`Signature::validate`]).
161#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
162#[non_exhaustive]
163pub enum SignatureError {
164    /// The name or email contains a character that would break the identity line.
165    #[error("signature {field} contains forbidden character {ch:?}")]
166    ForbiddenCharacter {
167        /// Field holding the character.
168        field: SignatureField,
169        /// The first forbidden character.
170        ch: char,
171    },
172    /// Git timestamps are unsigned seconds since the Unix epoch.
173    #[error("signature time is before the Unix epoch")]
174    BeforeEpoch,
175    /// The UTC offset is not a whole number of minutes.
176    #[error("signature UTC offset is not a whole number of minutes")]
177    SubMinuteOffset,
178    /// The text is not a `Name <email> <unix-seconds> <±HHMM>` identity line.
179    #[error("not an identity line of the form 'Name <email> <unix-seconds> <+HHMM>'")]
180    Malformed,
181}
182
183impl std::str::FromStr for Signature {
184    type Err = SignatureError;
185
186    /// Parse a full header identity line (`Name <email> <unix-seconds> <±HHMM>`).
187    ///
188    /// # Errors
189    ///
190    /// [`SignatureError::Malformed`] when the line does not have exactly one `<email>`, has a
191    /// missing, non-numeric, or overflowing date, or has text after the `±HHMM` timezone;
192    /// otherwise the [`Signature::validate`] errors.
193    fn from_str(line: &str) -> Result<Self, Self::Err> {
194        let ends_at_timezone =
195            parse_signature_times(line).is_some_and(|t| t.tz_hhmm_range.end == line.len());
196        if !ends_at_timezone || line.matches('<').count() != 1 || line.matches('>').count() != 1 {
197            return Err(SignatureError::Malformed);
198        }
199        let signature = parse_signature(line).ok_or(SignatureError::Malformed)?;
200        signature.validate()?;
201        Ok(signature)
202    }
203}
204
205/// Successful parse of the trailing `<unix> <+HHMM>` portion of a signature.
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct ParsedSignatureTimes {
208    /// Seconds in the author/committer field (before tz offset).
209    pub unix_seconds: i64,
210    /// Signed offset in seconds (from `+HHMM` / `-HHMM`).
211    pub tz_offset_secs: i64,
212    /// Byte range of the `+HHMM` / `-HHMM` field in the original `ident` string.
213    pub tz_hhmm_range: std::ops::Range<usize>,
214}
215
216/// Scan a decimal timestamp like Git's `parse_timestamp_from_buf` / `strtoumax`.
217/// Returns `None` if there is no digit or more than 21 digits (Git uses a 24-byte buffer).
218fn scan_decimal_timestamp(bytes: &[u8], mut i: usize) -> Option<(u128, usize)> {
219    const MAX_DIGITS: usize = 21;
220    let start = i;
221    let mut count = 0usize;
222    while i < bytes.len() && bytes[i].is_ascii_digit() {
223        count += 1;
224        if count > MAX_DIGITS {
225            return Some((u128::MAX, i));
226        }
227        i += 1;
228    }
229    if count == 0 {
230        return None;
231    }
232    let s = std::str::from_utf8(&bytes[start..i]).ok()?;
233    let v: u128 = s.parse().ok()?;
234    Some((v, i))
235}
236
237/// After `>` and one required space, skip only ASCII spaces and horizontal tabs (Git `fsck_ident`).
238fn skip_fsck_date_leading_ws(bytes: &[u8], mut i: usize) -> usize {
239    while i < bytes.len() && matches!(bytes[i], b' ' | b'\t') {
240        i += 1;
241    }
242    i
243}
244
245fn parse_tz_hhmm_offset(offset: &str) -> Option<i64> {
246    let b = offset.as_bytes();
247    if b.len() < 5 {
248        return None;
249    }
250    if !(b[0] == b'+' || b[0] == b'-') {
251        return None;
252    }
253    let sign = if b[0] == b'-' { -1i64 } else { 1i64 };
254    let hours: i64 = std::str::from_utf8(&b[1..3]).ok()?.parse().ok()?;
255    let minutes: i64 = std::str::from_utf8(&b[3..5]).ok()?.parse().ok()?;
256    Some(sign * (hours * 3600 + minutes * 60))
257}
258
259/// Parse `<unix> <+HHMM>` after the closing `>` of the email (Git commit author/committer line).
260#[must_use]
261pub fn parse_signature_times(ident: &str) -> Option<ParsedSignatureTimes> {
262    match parse_signature_tail(ident)? {
263        SignatureTail::Valid(p) => Some(p),
264        SignatureTail::Overflow | SignatureTail::NonNumeric => None,
265    }
266}
267
268/// Distinguishes a non-numeric date field from a numeric field that fails Git's overflow rules.
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub enum SignatureTail {
271    /// Well-formed timestamp and timezone.
272    Valid(ParsedSignatureTimes),
273    /// Digits and timezone present, but the number does not fit Git's `date_overflows` rules.
274    /// Default `%ad` shows the Unix epoch with `+0000` (t4212).
275    Overflow,
276    /// No leading digit after `>` (e.g. `totally_bogus`): `%ad` is empty, headers use epoch `+0000`.
277    NonNumeric,
278}
279
280/// Parse the date/time tail like [`parse_signature_times`], but preserve overflow vs non-numeric.
281#[must_use]
282pub fn parse_signature_tail(ident: &str) -> Option<SignatureTail> {
283    let bytes = ident.as_bytes();
284    let gt = ident.rfind('>')?;
285    let mut i = skip_fsck_date_leading_ws(bytes, gt + 1);
286    if i >= bytes.len() || !bytes[i].is_ascii_digit() {
287        return Some(SignatureTail::NonNumeric);
288    }
289    let (raw, after_digits) = scan_decimal_timestamp(bytes, i)?;
290    if after_digits >= bytes.len() || bytes[after_digits] != b' ' {
291        return None;
292    }
293    i = after_digits + 1;
294    if i + 5 > bytes.len() {
295        return None;
296    }
297    let tz_slice = ident.get(i..i + 5)?;
298    let tz_offset_secs = parse_tz_hhmm_offset(tz_slice)?;
299    let tz_hhmm_range = i..i + 5;
300    if raw == u128::MAX || raw > u64::MAX as u128 || date_overflows(raw as u64) {
301        return Some(SignatureTail::Overflow);
302    }
303    let unix_seconds = i64::try_from(raw).ok()?;
304    Some(SignatureTail::Valid(ParsedSignatureTimes {
305        unix_seconds,
306        tz_offset_secs,
307        tz_hhmm_range,
308    }))
309}
310
311/// Parse `Name <email> <unix> <±HHMM>` into a [`Signature`].
312///
313/// Returns [`None`] when the date field is missing, malformed, overflowed, or non-numeric
314/// (no sentinel epoch is synthesized).
315#[must_use]
316pub fn parse_signature(ident: &str) -> Option<Signature> {
317    let ParsedSignatureTimes {
318        unix_seconds,
319        tz_offset_secs,
320        ..
321    } = match parse_signature_tail(ident)? {
322        SignatureTail::Valid(p) => p,
323        SignatureTail::Overflow | SignatureTail::NonNumeric => return None,
324    };
325    let (name, email) = parse_identity_name_email(ident)?;
326    let offset_secs = i32::try_from(tz_offset_secs).ok()?;
327    let offset = UtcOffset::from_whole_seconds(offset_secs).ok()?;
328    let utc = OffsetDateTime::from_unix_timestamp(unix_seconds).ok()?;
329    Some(Signature {
330        name,
331        email,
332        time: utc.to_offset(offset),
333    })
334}
335
336fn parse_identity_name_email(ident: &str) -> Option<(String, String)> {
337    let lt = ident.find('<')?;
338    let name = ident[..lt].trim_end().to_owned();
339    let rest = &ident[lt + 1..];
340    let gt = rest.find('>')?;
341    let email = rest[..gt].trim().to_owned();
342    Some((name, email))
343}
344
345/// Classify the date field for pretty-print / `%at` (t4212 whitespace commits).
346pub fn signature_timestamp_for_pretty(ident: &str) -> SignatureTimestamp {
347    match parse_signature_times(ident) {
348        Some(p) => SignatureTimestamp::Valid(p.unix_seconds),
349        None => SignatureTimestamp::Sentinel,
350    }
351}
352
353/// Unix timestamp for `--until` / `--since` filtering (committer), matching Git's `parse_date`.
354/// Sentinel timestamps (whitespace dates, overflow, etc.) behave like `0` for cutoff comparisons
355/// (see t4212 `rev-list --until`).
356#[must_use]
357pub fn committer_timestamp_for_until_filter(ident: &str) -> i64 {
358    match signature_timestamp_for_pretty(ident) {
359        SignatureTimestamp::Valid(ts) => ts,
360        SignatureTimestamp::Sentinel => 0,
361    }
362}
363
364/// Raw unix seconds as i64 for `%at` when valid; `None` when Git would print nothing.
365#[must_use]
366pub fn timestamp_for_at_ct(ts: SignatureTimestamp) -> Option<i64> {
367    match ts {
368        SignatureTimestamp::Valid(v) => Some(v),
369        SignatureTimestamp::Sentinel => None,
370    }
371}
372
373/// First fsck error for commit headers (tree/parents/author/committer), or `Ok`.
374///
375/// Returns a [`crate::fsck_standalone::FsckError::report_line`] string (`msg-id: detail`); compare msg-ids to Git, not detail text.
376pub fn fsck_commit_idents(data: &[u8], hash_algo: HashAlgo) -> Result<(), String> {
377    crate::fsck_standalone::fsck_object(
378        ObjectKind::Commit,
379        data,
380        crate::fsck_standalone::FsckObjectOptions::new(hash_algo),
381    )
382    .map_err(|e| e.report_line())
383}
384
385/// Committer seconds for ordering (`rev-list --date-order`, etc.): unknown/corrupt → `0`.
386#[must_use]
387pub fn committer_unix_seconds_for_ordering(ident: &str) -> i64 {
388    match signature_timestamp_for_pretty(ident) {
389        SignatureTimestamp::Valid(ts) => ts,
390        SignatureTimestamp::Sentinel => 0,
391    }
392}
393
394/// Like [`committer_unix_seconds_for_ordering`] on raw identity bytes (ASCII commit headers).
395#[must_use]
396pub fn committer_unix_seconds_for_ordering_bytes(ident: &[u8]) -> i64 {
397    let Ok(ident) = std::str::from_utf8(ident) else {
398        return 0;
399    };
400    committer_unix_seconds_for_ordering(ident)
401}
402
403/// Like [`committer_timestamp_for_until_filter`] on raw identity bytes (ASCII commit headers).
404#[must_use]
405pub fn committer_timestamp_for_until_filter_bytes(ident: &[u8]) -> i64 {
406    let Ok(ident) = std::str::from_utf8(ident) else {
407        return 0;
408    };
409    committer_timestamp_for_until_filter(ident)
410}
411
412/// Name and email from a `git commit --author`-style argument (`Name <email>` or bare email).
413#[derive(Debug, Clone, PartialEq, Eq)]
414pub struct AuthorContact {
415    /// Display name (may be empty when the argument is a bare email address).
416    pub name: String,
417    /// Email address inside `<…>`.
418    pub email: String,
419}
420
421impl AuthorContact {
422    /// Stamp this contact with a timestamp, giving a [`Signature`].
423    ///
424    /// # Parameters
425    ///
426    /// - `date_override`: a `GIT_AUTHOR_DATE`-style date (`@<unix>`, `<unix> <±HHMM>`, ISO 8601,
427    ///   RFC 2822, …); when `None`, `now` is used.
428    /// - `now`: the current time; its UTC offset is used for `@<unix>` overrides.
429    ///
430    /// # Errors
431    ///
432    /// [`SignatureError::Malformed`] when `date_override` is not a date Git accepts, and the
433    /// [`Signature::validate`] errors for the name and email.
434    pub fn signature(
435        &self,
436        date_override: Option<&str>,
437        now: OffsetDateTime,
438    ) -> Result<Signature, SignatureError> {
439        let line = author_identity_line(self, date_override, now);
440        let time = parse_signature(&line)
441            .ok_or(SignatureError::Malformed)?
442            .time;
443        Signature::new(self.name.clone(), self.email.clone(), time)
444    }
445}
446
447/// Failure to parse [`parse_author_arg`].
448#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
449pub enum AuthorArgError {
450    /// The string is not a recognized author form.
451    #[error("invalid author: expected 'Name <email>' or a bare email address")]
452    InvalidFormat,
453    /// Angle brackets present but the email portion is empty.
454    #[error("invalid author: empty email")]
455    EmptyEmail,
456    /// Non-whitespace remains after the closing `>`.
457    #[error("invalid author: trailing text after '>'")]
458    TrailingText,
459}
460
461/// Parse `git commit --author` values: `Name <email>` or a bare email (no whitespace).
462///
463/// # Errors
464///
465/// Returns [`AuthorArgError`] when the string cannot be interpreted as an author.
466pub fn parse_author_arg(raw: &str) -> Result<AuthorContact, AuthorArgError> {
467    let s = raw.trim();
468    if s.is_empty() {
469        return Err(AuthorArgError::InvalidFormat);
470    }
471    if s.contains('\n') || s.contains('\r') {
472        return Err(AuthorArgError::InvalidFormat);
473    }
474    if let Some(lt) = s.find('<') {
475        let rest = &s[lt + 1..];
476        let gt = rest.find('>').ok_or(AuthorArgError::InvalidFormat)?;
477        let email = &rest[..gt];
478        if email.is_empty() {
479            return Err(AuthorArgError::EmptyEmail);
480        }
481        let after = rest[gt + 1..].trim();
482        if !after.is_empty() {
483            return Err(AuthorArgError::TrailingText);
484        }
485        let name = s[..lt].trim_end_matches(|c: char| c.is_ascii_whitespace());
486        return Ok(AuthorContact {
487            name: name.to_owned(),
488            email: email.to_owned(),
489        });
490    }
491    if s.contains('@') && !s.chars().any(char::is_whitespace) {
492        return Ok(AuthorContact {
493            name: String::new(),
494            email: s.to_owned(),
495        });
496    }
497    Err(AuthorArgError::InvalidFormat)
498}
499
500/// Build a full author/committer header line from a parsed contact and timestamp inputs.
501#[must_use]
502pub fn author_identity_line(
503    contact: &AuthorContact,
504    date_override: Option<&str>,
505    now: OffsetDateTime,
506) -> String {
507    assemble_identity(&contact.name, &contact.email, date_override, now)
508}
509
510/// Failure to normalize a commit author or committer string.
511#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
512pub enum CommitIdentityError {
513    /// Newlines, carriage returns, or other C0 controls (except tab) in the input.
514    #[error("invalid identity: embedded control character")]
515    EmbeddedControl,
516    /// The string is not a contact form, bare email, or full header line with timestamp.
517    #[error("invalid identity: expected 'Name <email>', a bare email, or a full header line with timestamp")]
518    Unrecognized,
519    /// [`parse_author_arg`] failure for contact-style input.
520    #[error(transparent)]
521    Author(#[from] AuthorArgError),
522}
523
524fn identity_contains_embedded_control(raw: &str) -> bool {
525    raw.bytes()
526        .any(|b| b == b'\n' || b == b'\r' || (b < 0x20 && b != b'\t'))
527}
528
529/// Replace the `<unix> <±HHMM>` suffix on a full or contact-style identity line.
530#[must_use]
531pub fn replace_identity_date_suffix(identity: &str, date_suffix: &str) -> String {
532    let suffix = date_suffix.trim();
533    if let Some((name, email)) = parse_identity_name_email(identity) {
534        if email.is_empty() && !name.is_empty() && name.contains('@') {
535            return format!("{} {suffix}", name.trim());
536        }
537        if email.is_empty() {
538            return format!("{} {suffix}", identity.trim_end());
539        }
540        return format!("{} <{email}> {suffix}", name);
541    }
542    format!("{} {suffix}", identity.trim_end())
543}
544
545/// Normalize author/committer input for writing a commit object.
546///
547/// Accepts either a full Git header line (`Name <email> <unix> <±HHMM>`) or a contact
548/// form (`Name <email>` / bare email). Contact forms are expanded with `now` and an
549/// optional `date_override` suffix (same rules as [`author_identity_line`]).
550///
551/// # Errors
552///
553/// Returns [`CommitIdentityError`] when the string is empty, contains embedded controls,
554/// or cannot be interpreted as a valid identity.
555pub fn prepare_commit_identity_line(
556    raw: &str,
557    date_override: Option<&str>,
558    now: OffsetDateTime,
559) -> Result<String, CommitIdentityError> {
560    if identity_contains_embedded_control(raw) {
561        return Err(CommitIdentityError::EmbeddedControl);
562    }
563    let trimmed = raw.trim();
564    if trimmed.is_empty() {
565        return Err(CommitIdentityError::Author(AuthorArgError::InvalidFormat));
566    }
567    if matches!(parse_signature_tail(trimmed), Some(SignatureTail::Valid(_))) {
568        let line = trimmed.to_owned();
569        return Ok(match date_override {
570            Some(suffix) => replace_identity_date_suffix(&line, suffix),
571            None => line,
572        });
573    }
574    if let Some(suffix) = date_override {
575        if parse_identity_name_email(trimmed).is_some() {
576            return Ok(replace_identity_date_suffix(trimmed, suffix));
577        }
578    }
579    let contact = parse_author_arg(trimmed)?;
580    Ok(author_identity_line(&contact, date_override, now))
581}
582
583/// Like [`prepare_commit_identity_line`], but preserves already-valid header bytes from an
584/// existing commit when `author_date` is not overridden.
585pub(crate) fn prepare_commit_author_from_parent(
586    parent_author: &str,
587    parent_author_raw: &[u8],
588    author_date: Option<&str>,
589    now: OffsetDateTime,
590) -> Result<(String, Vec<u8>), CommitIdentityError> {
591    if let Some(suffix) = author_date {
592        if identity_contains_embedded_control(parent_author) {
593            return Err(CommitIdentityError::EmbeddedControl);
594        }
595        let trimmed = parent_author.trim();
596        if trimmed.is_empty() {
597            return Err(CommitIdentityError::Author(AuthorArgError::InvalidFormat));
598        }
599        return Ok((replace_identity_date_suffix(trimmed, suffix), Vec::new()));
600    }
601    if !parent_author_raw.is_empty() {
602        return Ok((parent_author.to_owned(), parent_author_raw.to_vec()));
603    }
604    Ok((
605        prepare_commit_identity_line(parent_author, None, now)?,
606        Vec::new(),
607    ))
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    #[test]
615    fn non_numeric_author_date_is_non_numeric_tail() {
616        let ident = "A <e@x> totally_bogus -0700";
617        assert!(matches!(
618            parse_signature_tail(ident),
619            Some(SignatureTail::NonNumeric)
620        ));
621    }
622
623    #[test]
624    fn u128_max_digit_count_is_overflow_tail() {
625        let ident = "A <e@x> 18446744073709551617 -0700";
626        assert!(matches!(
627            parse_signature_tail(ident),
628            Some(SignatureTail::Overflow)
629        ));
630    }
631
632    #[test]
633    fn parse_author_arg_name_and_email() {
634        let c = parse_author_arg("A U Thor <author@example.com>").expect("parse");
635        assert_eq!(c.name, "A U Thor");
636        assert_eq!(c.email, "author@example.com");
637    }
638
639    #[test]
640    fn parse_author_arg_bare_email() {
641        let c = parse_author_arg("nobody@example.com").expect("parse");
642        assert!(c.name.is_empty());
643        assert_eq!(c.email, "nobody@example.com");
644    }
645
646    #[test]
647    fn parse_author_arg_rejects_empty_email_in_brackets() {
648        assert_eq!(parse_author_arg("Name <>"), Err(AuthorArgError::EmptyEmail));
649    }
650
651    #[test]
652    fn parse_author_arg_rejects_trailing_garbage() {
653        assert_eq!(
654            parse_author_arg("A <a@b.com> extra"),
655            Err(AuthorArgError::TrailingText)
656        );
657    }
658
659    #[test]
660    fn parse_author_arg_rejects_embedded_newline() {
661        assert_eq!(
662            parse_author_arg("Bad\nInjected <x@y.example>"),
663            Err(AuthorArgError::InvalidFormat)
664        );
665    }
666
667    #[test]
668    fn parse_signature_preserves_positive_offset() {
669        let ident = "Ada Lovelace <ada@example.com> 1700000000 +0530";
670        let sig = parse_signature(ident).expect("parse");
671        assert_eq!(sig.name, "Ada Lovelace");
672        assert_eq!(sig.email, "ada@example.com");
673        assert_eq!(
674            sig.format_rfc3339().as_deref(),
675            Some("2023-11-15T03:43:20+05:30")
676        );
677    }
678
679    #[test]
680    fn parse_signature_preserves_negative_offset() {
681        let ident = "Ada Lovelace <ada@example.com> 1700000000 -0800";
682        let sig = parse_signature(ident).expect("parse");
683        assert_eq!(
684            sig.format_rfc3339().as_deref(),
685            Some("2023-11-14T14:13:20-08:00")
686        );
687    }
688
689    #[test]
690    fn signature_from_str_round_trips_header_line() {
691        let line = "Ada Lovelace <ada@example.com> 1700000000 +0530";
692        let sig: Signature = line.parse().expect("parse");
693        assert_eq!(sig.name, "Ada Lovelace");
694        assert_eq!(sig.header_line(), line);
695    }
696
697    #[test]
698    fn signature_from_str_rejects_contact_and_injected_lines() {
699        for line in [
700            "Ada <ada@example.com>",
701            "A <a@b> <c@d> 1 +0000",
702            "Name <a@b>\nInjected: x 1 +0000",
703            "A <e@x> totally_bogus -0700",
704            "A <a@b> 1 +0000 trailing",
705            "A <a@b> 1 +00000",
706        ] {
707            assert_eq!(
708                line.parse::<Signature>(),
709                Err(SignatureError::Malformed),
710                "{line:?}"
711            );
712        }
713        assert!(matches!(
714            "Bad\u{1} <a@b> 1 +0000".parse::<Signature>(),
715            Err(SignatureError::ForbiddenCharacter { .. })
716        ));
717    }
718
719    #[test]
720    fn author_contact_signature_applies_date_override() {
721        let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("epoch");
722        let contact = parse_author_arg("Doc <doc@example.com>").expect("contact");
723        assert_eq!(
724            contact.signature(None, now).expect("now").header_line(),
725            "Doc <doc@example.com> 1700000000 +0000"
726        );
727        assert_eq!(
728            contact
729                .signature(Some("1234567890 -0800"), now)
730                .expect("override")
731                .header_line(),
732            "Doc <doc@example.com> 1234567890 -0800"
733        );
734        assert_eq!(
735            contact.signature(Some("not a date"), now),
736            Err(SignatureError::Malformed)
737        );
738    }
739
740    #[test]
741    fn parse_signature_malformed_identity_is_none() {
742        assert!(parse_signature("A <e@x> totally_bogus -0700").is_none());
743        assert!(parse_signature("not an identity").is_none());
744    }
745
746    #[test]
747    fn prepare_commit_identity_line_expands_contact_form() {
748        let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("epoch");
749        let line =
750            prepare_commit_identity_line("Doc <doc@example.com>", None, now).expect("prepare");
751        assert!(parse_signature(&line).is_some(), "full line: {line}");
752    }
753
754    #[test]
755    fn prepare_commit_identity_line_accepts_full_header() {
756        let ident = "Ada Lovelace <ada@example.com> 1700000000 +0530";
757        let now = OffsetDateTime::from_unix_timestamp(1).expect("epoch");
758        let line = prepare_commit_identity_line(ident, None, now).expect("prepare");
759        assert_eq!(line, ident);
760    }
761
762    #[test]
763    fn prepare_commit_identity_line_rejects_embedded_newline() {
764        let now = OffsetDateTime::from_unix_timestamp(1).expect("epoch");
765        assert_eq!(
766            prepare_commit_identity_line("Name <a@b>\nInjected", None, now),
767            Err(CommitIdentityError::EmbeddedControl)
768        );
769    }
770
771    #[test]
772    fn prepare_commit_identity_line_rejects_bare_name_without_email() {
773        let now = OffsetDateTime::from_unix_timestamp(1).expect("epoch");
774        assert!(matches!(
775            prepare_commit_identity_line("no email here", None, now),
776            Err(CommitIdentityError::Author(AuthorArgError::InvalidFormat))
777        ));
778    }
779
780    #[test]
781    fn prepare_commit_identity_line_date_override_rewrites_non_numeric_tail() {
782        let ident = "A U Thor <author@example.com> totally_bogus -0700";
783        let now = OffsetDateTime::from_unix_timestamp(1).expect("epoch");
784        let line =
785            prepare_commit_identity_line(ident, Some("946684800 +0000"), now).expect("override");
786        assert_eq!(line, "A U Thor <author@example.com> 946684800 +0000");
787        assert!(parse_signature(&line).is_some());
788    }
789
790    #[test]
791    fn prepare_commit_author_from_parent_date_override_rewrites_non_numeric_tail() {
792        let parent = "A U Thor <author@example.com> totally_bogus -0700";
793        let now = OffsetDateTime::from_unix_timestamp(1).expect("epoch");
794        let (line, raw) = prepare_commit_author_from_parent(
795            parent,
796            parent.as_bytes(),
797            Some("946684800 +0000"),
798            now,
799        )
800        .expect("override");
801        assert!(raw.is_empty());
802        assert_eq!(line, "A U Thor <author@example.com> 946684800 +0000");
803    }
804}