1use time::format_description::well_known::Rfc3339;
28use time::OffsetDateTime;
29
30use crate::git_date::parse::{parse_date, DateParseError};
31
32pub use crate::commit_pretty::resolve_core_abbrev_len;
33
34pub(crate) fn parse_date_to_git_timestamp(date_str: &str) -> Option<String> {
43 let trimmed = date_str.trim();
44
45 if let Ok(dt) = OffsetDateTime::parse(trimmed, &Rfc3339) {
48 return Some(format_git_timestamp(dt));
49 }
50 let with_utc_z = format!("{trimmed}Z");
51 if let Ok(dt) = OffsetDateTime::parse(&with_utc_z, &Rfc3339) {
52 return Some(format_git_timestamp(dt));
53 }
54
55 let parts: Vec<&str> = trimmed.rsplitn(2, ' ').collect();
57 if parts.len() == 2 {
58 let maybe_epoch = parts[1];
59 if maybe_epoch.chars().all(|c| c.is_ascii_digit()) {
60 return None;
62 }
63 }
64
65 if parts.len() == 2 {
67 let tz = parts[0];
68 let datetime = parts[1];
69
70 let tz_bytes = tz.as_bytes();
72 if tz_bytes.len() >= 5 {
73 let sign: i64 = if tz_bytes[0] == b'-' { -1 } else { 1 };
74 let h: i64 = tz[1..3].parse().unwrap_or(0);
75 let m: i64 = tz[3..5].parse().unwrap_or(0);
76 let tz_secs = sign * (h * 3600 + m * 60);
77
78 if let Ok(offset) = time::UtcOffset::from_whole_seconds(tz_secs as i32) {
80 let fmt = time::format_description::parse_borrowed::<1>(
81 "[year]-[month]-[day] [hour]:[minute]:[second]",
82 )
83 .ok()?;
84 if let Ok(naive) = time::PrimitiveDateTime::parse(datetime, &fmt) {
85 let dt = naive.assume_offset(offset);
86 let epoch = dt.unix_timestamp();
87 return Some(format!("{epoch} {tz}"));
88 }
89 }
90 }
91 }
92
93 if let Some(epoch_str) = trimmed.strip_prefix('@') {
95 let ep_parts: Vec<&str> = epoch_str.splitn(2, ' ').collect();
97 if ep_parts.len() == 2 {
98 if let Ok(_epoch) = ep_parts[0].parse::<i64>() {
99 return Some(format!("{} {}", ep_parts[0], ep_parts[1]));
100 }
101 }
102 }
103
104 if let Ok(canonical) = parse_date(trimmed) {
106 return Some(canonical);
107 }
108
109 None
110}
111
112fn is_canonical_epoch_numeric_tz(s: &str) -> bool {
114 let Some((epoch, tz)) = s.rsplit_once(' ') else {
115 return false;
116 };
117 if epoch.is_empty() || !epoch.as_bytes().iter().all(|b| b.is_ascii_digit()) {
118 return false;
119 }
120 let tz = tz.as_bytes();
121 if tz.len() != 5 || (tz[0] != b'+' && tz[0] != b'-') {
122 return false;
123 }
124 if !tz[1..].iter().all(|b| b.is_ascii_digit()) {
125 return false;
126 }
127 let hh = (tz[1] - b'0') as u32 * 10 + (tz[2] - b'0') as u32;
128 let mm = (tz[3] - b'0') as u32 * 10 + (tz[4] - b'0') as u32;
129 hh <= 23 && mm <= 59
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct TrailerArgParseError {
135 raw: String,
136}
137
138impl std::fmt::Display for TrailerArgParseError {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 write!(
141 f,
142 "trailer must be key=value or key: value (got '{}')",
143 self.raw
144 )
145 }
146}
147
148impl std::error::Error for TrailerArgParseError {}
149
150pub fn parse_trailer_command_line_arg(raw: &str) -> Result<(String, String), TrailerArgParseError> {
159 let raw = raw.trim();
160 let sep_pos = raw
161 .char_indices()
162 .find(|(_, c)| *c == ':' || *c == '=')
163 .map(|(i, _)| i);
164 let Some(pos) = sep_pos else {
165 return Err(TrailerArgParseError {
166 raw: raw.to_owned(),
167 });
168 };
169 Ok((
170 raw[..pos].trim().to_owned(),
171 raw[pos + 1..].trim().to_owned(),
172 ))
173}
174
175pub fn normalize_commit_date_override(raw: &str) -> Result<String, DateParseError> {
186 let trimmed = raw.trim();
187 if is_canonical_epoch_numeric_tz(trimmed) {
188 return Ok(trimmed.to_owned());
189 }
190 if let Some(ts) = parse_date_to_git_timestamp(trimmed) {
191 if is_canonical_epoch_numeric_tz(&ts) {
192 return Ok(ts);
193 }
194 }
195 parse_date(trimmed)
196}
197
198pub fn format_git_timestamp(dt: OffsetDateTime) -> String {
200 let epoch = dt.unix_timestamp();
201 let offset = dt.offset();
202 let sign = if offset.is_negative() { '-' } else { '+' };
203 let hours = offset.whole_hours().unsigned_abs();
204 let minutes = offset.minutes_past_hour().unsigned_abs();
205 format!("{epoch} {sign}{hours:02}{minutes:02}")
206}
207
208#[must_use]
216pub(crate) fn assemble_identity(
217 name: &str,
218 email: &str,
219 date_override: Option<&str>,
220 now: OffsetDateTime,
221) -> String {
222 let timestamp = match date_override {
223 Some(d) => {
224 let trimmed = d.trim();
225 if let Some(rest) = trimmed.strip_prefix('@') {
226 let mut parts = rest.split_whitespace();
227 if let Some(epoch_str) = parts.next() {
228 if parts.next().is_none() {
229 if let Ok(epoch) = epoch_str.parse::<i64>() {
230 if let Ok(dt) = OffsetDateTime::from_unix_timestamp(epoch)
231 .map(|t| t.to_offset(now.offset()))
232 {
233 return format!("{name} <{email}> {}", format_git_timestamp(dt));
234 }
235 }
236 }
237 }
238 }
239 normalize_commit_date_override(d).unwrap_or_else(|_| d.to_string())
240 }
241 None => format_git_timestamp(now),
242 };
243 format!("{name} <{email}> {timestamp}")
244}
245
246pub mod encoding {
248 pub use crate::commit_encoding::*;
249}
250
251pub mod trailers {
253 pub use crate::commit_trailers::*;
254 pub mod interpret {
256 pub use crate::interpret_trailers::*;
257 }
258}
259
260pub mod signing {
262 pub use crate::signing::*;
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use crate::environment::Environment;
269 use std::ffi::OsString;
270 use time::OffsetDateTime;
271
272 #[test]
273 fn assemble_identity_formats_supplied_now_without_override() {
274 let now = OffsetDateTime::from_unix_timestamp(1_112_911_993).unwrap();
275 let line = assemble_identity("A U Thor", "author@example.com", None, now);
276 assert_eq!(line, "A U Thor <author@example.com> 1112911993 +0000");
277 }
278
279 #[test]
280 fn format_git_timestamp_keeps_sign_of_sub_hour_negative_offsets() {
281 let at = |h, m| {
282 OffsetDateTime::from_unix_timestamp(1_700_000_000)
283 .unwrap()
284 .to_offset(time::UtcOffset::from_hms(h, m, 0).unwrap())
285 };
286 assert_eq!(format_git_timestamp(at(0, -30)), "1700000000 -0030");
287 assert_eq!(format_git_timestamp(at(-9, -30)), "1700000000 -0930");
288 assert_eq!(format_git_timestamp(at(5, 45)), "1700000000 +0545");
289 assert_eq!(format_git_timestamp(at(0, 0)), "1700000000 +0000");
290 }
291
292 #[test]
293 fn assemble_identity_respects_tz_from_environment() {
294 let vars = [("TZ", "+0200")]
295 .into_iter()
296 .map(|(k, v)| (OsString::from(k), OsString::from(v)));
297 let env = Environment::from_vars(vars, std::path::PathBuf::from("/tmp"));
298 let now = crate::environment::offset_from_epoch_and_tz(1_112_911_993, env.tz.as_deref());
299 let line = assemble_identity("A U Thor", "author@example.com", None, now);
300 assert_eq!(line, "A U Thor <author@example.com> 1112911993 +0200");
301 }
302
303 #[test]
304 fn assemble_identity_parses_date_override() {
305 let now = OffsetDateTime::from_unix_timestamp(0).unwrap();
306 let line = assemble_identity(
307 "A U Thor",
308 "author@example.com",
309 Some("2005-04-07T22:13:13 +0000"),
310 now,
311 );
312 assert_eq!(line, "A U Thor <author@example.com> 1112911993 +0000");
313 }
314
315 #[test]
316 fn parse_trailer_command_line_arg_splits_on_first_colon_or_equals() {
317 let (k, v) = parse_trailer_command_line_arg("Link: https://x?a=b").unwrap();
318 assert_eq!(k, "Link");
319 assert_eq!(v, "https://x?a=b");
320 let (k, v) = parse_trailer_command_line_arg("Acked-by: Alice=Bob").unwrap();
321 assert_eq!(k, "Acked-by");
322 assert_eq!(v, "Alice=Bob");
323 let (k, v) = parse_trailer_command_line_arg("key=value").unwrap();
324 assert_eq!(k, "key");
325 assert_eq!(v, "value");
326 }
327
328 #[test]
329 fn normalize_commit_date_override_rejects_invalid_tz_suffix() {
330 assert_eq!(
331 normalize_commit_date_override("946684800 garbage").unwrap(),
332 "946684800 +0000"
333 );
334 assert_eq!(
335 normalize_commit_date_override("@946684800 garbage").unwrap(),
336 "946684800 +0000"
337 );
338 assert_eq!(
339 normalize_commit_date_override("946684800 +0000").unwrap(),
340 "946684800 +0000"
341 );
342 assert_eq!(
343 normalize_commit_date_override("946684800 +0530").unwrap(),
344 "946684800 +0530"
345 );
346 }
347
348 #[test]
349 fn assemble_identity_normalizes_bad_tz_suffix_like_git() {
350 let now = OffsetDateTime::from_unix_timestamp(0).unwrap();
351 let line = assemble_identity(
352 "A U Thor",
353 "author@example.com",
354 Some("946684800 garbage"),
355 now,
356 );
357 assert_eq!(line, "A U Thor <author@example.com> 946684800 +0000");
358 }
359
360 #[test]
361 fn assemble_identity_at_epoch_uses_now_offset() {
362 let vars = [("TZ", "America/New_York")]
363 .into_iter()
364 .map(|(k, v)| (OsString::from(k), OsString::from(v)));
365 let env = Environment::from_vars(vars, std::path::PathBuf::from("/tmp"));
366 let now = crate::environment::offset_from_epoch_and_tz(1_112_911_993, env.tz.as_deref());
367 let line = assemble_identity("A U Thor", "author@example.com", Some("@1112911993"), now);
368 assert_eq!(line, "A U Thor <author@example.com> 1112911993 -0400");
369 }
370}