1extern crate proc_macro;
4
5use std::env;
6use std::error::Error;
7use std::path::{Path, PathBuf};
8use std::process::{Command, Stdio};
9
10use proc_macro::TokenStream;
11use proc_macro2::Span;
12use quote::quote;
13use syn::parse::{Parse, ParseStream};
14use syn::{parse, Visibility};
15use syn::{parse_macro_input, Ident, LitStr};
16
17use log::warn;
18
19use time::{format_description::FormatItem, macros::format_description, OffsetDateTime, UtcOffset};
20
21const DATE_FORMAT: &[FormatItem<'_>] = format_description!("[year]-[month]-[day]");
22
23struct TestamentOptions {
24 crate_: Ident,
25 name: Ident,
26 vis: Option<Visibility>,
27}
28
29impl Parse for TestamentOptions {
30 fn parse(input: ParseStream) -> parse::Result<Self> {
31 let crate_ = input.parse()?;
32 let name = input.parse()?;
33 let vis = if input.is_empty() {
34 None
35 } else {
36 Some(input.parse()?)
37 };
38 Ok(TestamentOptions { crate_, name, vis })
39 }
40}
41
42struct StaticTestamentOptions {
43 crate_: Ident,
44 name: Ident,
45 trusted: Option<LitStr>,
46}
47
48impl Parse for StaticTestamentOptions {
49 fn parse(input: ParseStream) -> parse::Result<Self> {
50 Ok(StaticTestamentOptions {
51 crate_: input.parse()?,
52 name: input.parse()?,
53 trusted: input.parse()?,
54 })
55 }
56}
57
58fn run_git<GD>(dir: GD, args: &[&str]) -> Result<Vec<u8>, Box<dyn Error>>
59where
60 GD: AsRef<Path>,
61{
62 let output = Command::new("git")
63 .args(args)
64 .stdin(Stdio::null())
65 .current_dir(dir)
66 .output()?;
67 if output.status.success() {
68 Ok(output.stdout)
69 } else {
70 Err(String::from_utf8(output.stderr)?.into())
71 }
72}
73
74fn find_git_dir() -> Result<PathBuf, Box<dyn Error>> {
75 let dir = run_git(
77 env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR env variable not set"),
78 &["rev-parse", "--show-toplevel"],
79 )?;
80 Ok(String::from_utf8(dir)?.trim_end().into())
83}
84
85fn revparse_single(git_dir: &Path, refname: &str) -> Result<(String, i64, i32), Box<dyn Error>> {
86 let sha = String::from_utf8(run_git(git_dir, &["rev-parse", refname])?)?
88 .trim_end()
89 .to_owned();
90 let show = String::from_utf8(run_git(git_dir, &["cat-file", "-p", &sha])?)?;
91
92 for line in show.lines() {
93 if line.starts_with("committer ") {
94 let parts: Vec<&str> = line.split_whitespace().collect();
95 if parts.len() < 2 {
96 return Err(format!("Insufficient committer data in {line}").into());
97 }
98 let time: i64 = parts[parts.len() - 2].parse()?;
99 let offset: &str = parts[parts.len() - 1];
100 if offset.len() != 5 {
101 return Err(
102 format!("Insufficient/Incorrect data in timezone offset: {offset}").into(),
103 );
104 }
105 let hours: i32 = offset[1..=2].parse()?;
106 let mins: i32 = offset[3..=4].parse()?;
107 let absoffset: i32 = mins + (hours * 60);
108 let offset: i32 = if offset.starts_with('-') {
109 -absoffset
111 } else {
112 absoffset
114 };
115 return Ok((sha, time, offset));
116 } else if line.is_empty() {
117 return Err(format!("Unable to find committer information in {refname}").into());
119 }
120 }
121
122 Err("Somehow fell off the end of the commit data".into())
123}
124
125fn branch_name(dir: &Path) -> Result<Option<String>, Box<dyn Error>> {
126 let symref = match run_git(dir, &["symbolic-ref", "-q", "HEAD"]) {
127 Ok(s) => s,
128 Err(_) => run_git(dir, &["name-rev", "--name-only", "HEAD"])?,
129 };
130 let mut name = String::from_utf8(symref)?.trim().to_owned();
131 if name.starts_with("refs/heads/") {
132 name = name[11..].to_owned();
133 }
134 if name.is_empty() {
135 Ok(None)
136 } else {
137 Ok(Some(name))
138 }
139}
140
141fn describe(dir: &Path, sha: &str) -> Result<String, Box<dyn Error>> {
142 Ok(
144 String::from_utf8(run_git(dir, &["describe", "--tags", "--long", sha])?)?
145 .trim_end()
146 .to_owned(),
147 )
148}
149
150#[derive(Clone, Copy)]
151enum StatusFlag {
152 Added,
153 Deleted,
154 Modified,
155 Untracked,
156}
157use StatusFlag::*;
158
159#[derive(Clone)]
160struct StatusEntry {
161 path: String,
162 status: StatusFlag,
163}
164
165fn status(dir: &Path) -> Result<Vec<StatusEntry>, Box<dyn Error>> {
166 let info = String::from_utf8(run_git(
168 dir,
169 &[
170 "status",
171 "--porcelain",
172 "--untracked-files=normal",
173 "--ignore-submodules=all",
174 ],
175 )?)?;
176
177 let mut ret = Vec::new();
178
179 for line in info.lines() {
180 let index_change = line.chars().next().unwrap();
181 let worktree_change = line.chars().nth(1).unwrap();
182 match (index_change, worktree_change) {
183 ('?', _) | (_, '?') => ret.push(StatusEntry {
184 path: line[3..].to_owned(),
185 status: Untracked,
186 }),
187 ('A', _) | (_, 'A') => ret.push(StatusEntry {
188 path: line[3..].to_owned(),
189 status: Added,
190 }),
191 ('M', _) | (_, 'M') => ret.push(StatusEntry {
192 path: line[3..].to_owned(),
193 status: Modified,
194 }),
195 ('D', _) | (_, 'D') => ret.push(StatusEntry {
196 path: line[3..].to_owned(),
197 status: Deleted,
198 }),
199 _ => {}
200 }
201 }
202
203 Ok(ret)
204}
205
206struct InvocationInformation {
207 pkgver: String,
208 now: String,
209}
210
211impl InvocationInformation {
212 fn acquire() -> Self {
213 let pkgver = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "?.?.?".to_owned());
214 let now = OffsetDateTime::now_utc();
215 let now = now.format(DATE_FORMAT).expect("unable to format now");
216 let sde = match env::var("SOURCE_DATE_EPOCH") {
217 Ok(sde) => match sde.parse::<i64>() {
218 Ok(sde) => Some(
219 OffsetDateTime::from_unix_timestamp(sde)
220 .expect("couldn't contruct datetime from source date epoch")
221 .format(DATE_FORMAT)
222 .expect("couldn't format source date epoch datetime"),
223 ),
224 Err(_) => None,
225 },
226 Err(_) => None,
227 };
228 let now = sde.unwrap_or(now);
229
230 Self { pkgver, now }
231 }
232}
233
234#[derive(Clone)]
235struct CommitInfo {
236 id: String,
237 date: String,
238 tag: String,
239 distance: usize,
240}
241
242#[derive(Clone)]
243struct GitInformation {
244 branch: Option<String>,
245 commitinfo: Option<CommitInfo>,
246 status: Vec<StatusEntry>,
247}
248
249impl GitInformation {
250 fn acquire() -> Result<Self, Box<dyn std::error::Error>> {
251 let git_dir = find_git_dir()?;
252 let branch = match branch_name(&git_dir) {
253 Ok(b) => b,
254 Err(e) => {
255 warn!("Unable to determine branch name: {e}");
256 None
257 }
258 };
259
260 let commitinfo = (|| {
261 let (commit, commit_time, commit_offset) = match revparse_single(&git_dir, "HEAD") {
262 Ok(commit_data) => commit_data,
263 Err(e) => {
264 warn!("No commit at HEAD: {e}");
265 return None;
266 }
267 };
268 let commit_id = commit;
270 let naive =
271 OffsetDateTime::from_unix_timestamp(commit_time).expect("Invalid commit time");
272 let offset = UtcOffset::from_whole_seconds(commit_offset * 60)
273 .expect("Invalid UTC offset (seconds)");
274 let commit_time = naive.replace_offset(offset);
275 let commit_date = commit_time
276 .format(DATE_FORMAT)
277 .expect("unable to format commit date");
278
279 let (tag, distance) = match describe(&git_dir, &commit_id) {
280 Ok(res) => {
281 let res = &res[..res.rfind('-').expect("No commit info in describe!")];
282 let tag_name = &res[..res.rfind('-').expect("No commit count in describe!")];
283 let commit_count = res[tag_name.len() + 1..]
284 .parse::<usize>()
285 .expect("Unable to parse commit count in describe!");
286 (tag_name.to_owned(), commit_count)
287 }
288 Err(e) => {
289 warn!("No tag info found!\n{:?}", e);
290 ("".to_owned(), 0)
291 }
292 };
293
294 Some(CommitInfo {
295 id: commit_id,
296 date: commit_date,
297 tag,
298 distance,
299 })
300 })();
301
302 let status = if commitinfo.is_some() {
303 status(&git_dir).expect("Unable to generate status information")
304 } else {
305 vec![]
306 };
307
308 Ok(Self {
309 branch,
310 commitinfo,
311 status,
312 })
313 }
314}
315
316#[proc_macro]
317pub fn git_testament(input: TokenStream) -> TokenStream {
318 let TestamentOptions { crate_, name, vis } = parse_macro_input!(input);
319
320 let InvocationInformation { pkgver, now } = InvocationInformation::acquire();
321 let gitinfo = match GitInformation::acquire() {
322 Ok(gi) => gi,
323 Err(e) => {
324 warn!(
325 "Unable to open a repo at {}: {}",
326 env::var("CARGO_MANIFEST_DIR").unwrap(),
327 e
328 );
329 return (quote! {
330 #[allow(clippy::needless_update)]
331 #vis const #name: #crate_::GitTestament<'static> = #crate_::GitTestament {
332 commit: #crate_::CommitKind::NoRepository(#pkgver, #now),
333 .. #crate_::EMPTY_TESTAMENT
334 };
335 })
336 .into();
337 }
338 };
339
340 let branch_name = {
342 if let Some(branch) = gitinfo.branch {
343 quote! {#crate_::__core::option::Option::Some(#branch)}
344 } else {
345 quote! {#crate_::__core::option::Option::None}
346 }
347 };
348
349 if gitinfo.commitinfo.is_none() {
351 return (quote! {
352 #[allow(clippy::needless_update)]
353 #vis const #name: #crate_::GitTestament<'static> = #crate_::GitTestament {
354 commit: #crate_::CommitKind::NoCommit(#pkgver, #now),
355 branch_name: #branch_name,
356 .. #crate_::EMPTY_TESTAMENT
357 };
358 })
359 .into();
360 }
361
362 let commitinfo = gitinfo.commitinfo.as_ref().unwrap();
363
364 let commit = if !commitinfo.tag.is_empty() {
365 let (tag, id, date, distance) = (
367 &commitinfo.tag,
368 &commitinfo.id,
369 &commitinfo.date,
370 commitinfo.distance,
371 );
372 quote! {
373 #crate_::CommitKind::FromTag(#tag, #id, #date, #distance)
374 }
375 } else {
376 let (id, date) = (&commitinfo.id, &commitinfo.date);
377 quote! {
378 #crate_::CommitKind::NoTags(#id, #date)
379 }
380 };
381
382 let statuses: Vec<_> = gitinfo
384 .status
385 .iter()
386 .map(|status| {
387 let path = status.path.clone().into_bytes();
388 match status.status {
389 Untracked => quote! {
390 #crate_::GitModification::Untracked(&[#(#path),*])
391 },
392 Added => quote! {
393 #crate_::GitModification::Added(&[#(#path),*])
394 },
395 Modified => quote! {
396 #crate_::GitModification::Modified(&[#(#path),*])
397 },
398 Deleted => quote! {
399 #crate_::GitModification::Removed(&[#(#path),*])
400 },
401 }
402 })
403 .collect();
404
405 (quote! {
406 #[allow(clippy::needless_update)]
407 #vis const #name: #crate_::GitTestament<'static> = #crate_::GitTestament {
408 commit: #commit,
409 modifications: &[#(#statuses),*],
410 branch_name: #branch_name,
411 .. #crate_::EMPTY_TESTAMENT
412 };
413 })
414 .into()
415}
416
417#[proc_macro]
418pub fn git_testament_macros(input: TokenStream) -> TokenStream {
419 let StaticTestamentOptions {
420 crate_,
421 name,
422 trusted,
423 } = parse_macro_input!(input);
424 let sname = name.to_string();
425 let (pkgver, now, gitinfo, macros) = macro_content(&crate_, &sname);
426
427 let testament = if let Some(gitinfo) = gitinfo {
429 let commitstr = if let Some(ref commitinfo) = gitinfo.commitinfo {
430 if commitinfo.tag.is_empty() {
431 format!("unknown ({} {})", &commitinfo.id[..9], commitinfo.date)
433 } else {
434 let trusted = if gitinfo.branch == trusted.map(|v| v.value()) {
435 gitinfo.status.is_empty()
436 } else {
437 false
438 };
439 if trusted {
441 format!("{} ({} {})", pkgver, &commitinfo.id[..9], commitinfo.date)
442 } else {
443 let basis = if commitinfo.distance > 0 {
444 format!(
445 "{}+{} ({} {})",
446 commitinfo.tag,
447 commitinfo.distance,
448 &commitinfo.id[..9],
449 commitinfo.date
450 )
451 } else {
452 format!(
454 "{} ({} {})",
455 commitinfo.tag,
456 &commitinfo.id[..9],
457 commitinfo.date
458 )
459 };
460 if commitinfo.tag.contains(&pkgver) {
461 basis
462 } else {
463 format!("{pkgver} :: {basis}")
464 }
465 }
466 }
467 } else {
468 format!("{pkgver} (uncommitted {now})")
470 };
471 if gitinfo.status.is_empty() {
472 commitstr
473 } else {
474 format!(
475 "{} dirty {} modification{}",
476 commitstr,
477 gitinfo.status.len(),
478 if gitinfo.status.len() == 1 { "" } else { "s" }
479 )
480 }
481 } else {
482 format!("{pkgver} ({now})")
484 };
485
486 let mac_testament = concat_ident(&sname, "testament");
487
488 (quote! {
489 #macros
490 #[allow(unused_macros)]
491 macro_rules! #mac_testament { () => {#testament}}
492 })
493 .into()
494}
495
496fn macro_content(
497 crate_: &Ident,
498 prefix: &str,
499) -> (String, String, Option<GitInformation>, impl quote::ToTokens) {
500 let InvocationInformation { pkgver, now } = InvocationInformation::acquire();
501 let mac_branch = concat_ident(prefix, "branch");
502 let mac_repo_present = concat_ident(prefix, "repo_present");
503 let mac_commit_present = concat_ident(prefix, "commit_present");
504 let mac_tag_present = concat_ident(prefix, "tag_present");
505 let mac_commit_hash = concat_ident(prefix, "commit_hash");
506 let mac_commit_date = concat_ident(prefix, "commit_date");
507 let mac_tag_name = concat_ident(prefix, "tag_name");
508 let mac_tag_distance = concat_ident(prefix, "tag_distance");
509 let gitinfo = match GitInformation::acquire() {
510 Ok(gi) => gi,
511 Err(e) => {
512 warn!(
513 "Unable to open a repo at {}: {}",
514 env::var("CARGO_MANIFEST_DIR").unwrap(),
515 e
516 );
517 return (
518 pkgver.clone(),
519 now.clone(),
520 None,
521 quote! {
522 #[allow(unused_macros)]
523 macro_rules! #mac_branch { () => {None}}
524 #[allow(unused_macros)]
525 macro_rules! #mac_repo_present { () => {false}}
526 #[allow(unused_macros)]
527 macro_rules! #mac_commit_present { () => {false}}
528 #[allow(unused_macros)]
529 macro_rules! #mac_tag_present { () => {false}}
530 #[allow(unused_macros)]
531 macro_rules! #mac_commit_hash { () => {#pkgver}}
532 #[allow(unused_macros)]
533 macro_rules! #mac_commit_date { () => {#now}}
534 #[allow(unused_macros)]
535 macro_rules! #mac_tag_name { () => {#pkgver}}
536 #[allow(unused_macros)]
537 macro_rules! #mac_tag_distance { () => {0}}
538 },
539 );
540 }
541 };
542
543 let branch_name = {
544 if let Some(ref branch) = gitinfo.branch {
545 quote! {#crate_::__core::option::Option::Some(#branch)}
546 } else {
547 quote! {#crate_::__core::option::Option::None}
548 }
549 };
550
551 let basics = quote! {
552 #[allow(unused_macros)]
553 macro_rules! #mac_repo_present { () => {true}}
554 #[allow(unused_macros)]
555 macro_rules! #mac_branch { () => {#branch_name}}
556 };
557
558 if gitinfo.commitinfo.is_none() {
560 return (
561 pkgver.clone(),
562 now.clone(),
563 Some(gitinfo),
564 quote! {
565 #basics
566 #[allow(unused_macros)]
567 macro_rules! #mac_commit_present { () => {false}}
568 #[allow(unused_macros)]
569 macro_rules! #mac_tag_present { () => {false}}
570 #[allow(unused_macros)]
571 macro_rules! #mac_commit_hash { () => {#pkgver}}
572 #[allow(unused_macros)]
573 macro_rules! #mac_commit_date { () => {#now}}
574 #[allow(unused_macros)]
575 macro_rules! #mac_tag_name { () => {#pkgver}}
576 #[allow(unused_macros)]
577 macro_rules! #mac_tag_distance { () => {0}}
578 },
579 );
580 }
581
582 let commitinfo = gitinfo.commitinfo.as_ref().unwrap();
583 let (commit_hash, commit_date) = (&commitinfo.id, &commitinfo.date);
584 let (tag, distance) = (&commitinfo.tag, commitinfo.distance);
585
586 let basics = quote! {
587 #basics
588 #[allow(unused_macros)]
589 macro_rules! #mac_commit_present { () => {true}}
590 #[allow(unused_macros)]
591 macro_rules! #mac_commit_hash { () => {#commit_hash}}
592 #[allow(unused_macros)]
593 macro_rules! #mac_commit_date { () => {#commit_date}}
594 };
595
596 (
597 pkgver.clone(),
598 now,
599 Some(gitinfo.clone()),
600 if commitinfo.tag.is_empty() {
601 quote! {
602 #basics
603 #[allow(unused_macros)]
604 macro_rules! #mac_tag_present { () => {false}}
605 #[allow(unused_macros)]
606 macro_rules! #mac_tag_name { () => {#pkgver}}
607 #[allow(unused_macros)]
608 macro_rules! #mac_tag_distance { () => {0}}
609 }
610 } else {
611 quote! {
612 #basics
613 #[allow(unused_macros)]
614 macro_rules! #mac_tag_present { () => {true}}
615 #[allow(unused_macros)]
616 macro_rules! #mac_tag_name { () => {#tag}}
617 #[allow(unused_macros)]
618 macro_rules! #mac_tag_distance { () => {#distance}}
619 }
620 },
621 )
622}
623
624fn concat_ident(prefix: &str, suffix: &str) -> Ident {
625 Ident::new(&format!("{prefix}_{suffix}"), Span::call_site())
626}