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